Freie Gestaltung statt Tokenlisten (Dokumentschema v10)

Der bisherige Editor bot drei Farben, keine Schriftwahl und elf
Auswahllisten mit 44 festen Werten. ADR 008 hatte das mit Sicherheit
begründet - der Schluss war falsch: die Alternative zu beliebigem
CSS-Text ist nicht eine Auswahlliste, sondern ein typisiertes
Designmodell.

StyleSchema erklärt jede Eigenschaft einmal mit Typ, CSS-Entsprechung
und Grenzen. Validator, CSS-Erzeugung und Editorpanel lesen dieselbe
Definition, also kann der Editor nichts anbieten, was der Server
ablehnt. Rund fünfzig Eigenschaften in sieben Gruppen, je Block, je
Gerät und für den Hover-Zustand, mit Vererbung und einzelnem
Zurücksetzen.

Werte sind kurze Zeichenketten mit verankertem Muster, nie CSS-Text;
zusammengesetzte Deklarationen baut der Compiler selbst. Der Server
erzeugt daraus pro Seite ein blockgenaues Stylesheet, das über seinen
eigenen SHA-256-Hash erlaubt wird statt über unsafe-inline.

Theme unter /admin/theme: freie Farbpalette als @schlüssel, Schriften
aus Systemschrift oder Google Fonts, Grundtypografie mit modularer
Überschriftenskala. Google Fonts auf ausdrückliche Nutzerentscheidung;
die Richtlinie öffnet die Hosts nur, wenn wirklich eine eingestellt
ist.

Alte Tokenwerte aus v2 bis v9 bleiben gültig und im Editor unter
"Klassische Werte dieser Seite" bearbeitbar.

Geprüft: 154 PHP-Tests / 563 Assertions unter PHP 8.2.12 und 8.4.22,
15 JavaScript-Modelltests, Lint/Format/Container. Im Browser wurde nur
der Aufbau des Panels bestätigt, kein Gestaltungsvorgang von Hand
ausgeführt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-12 13:04:54 +02:00
1 parent a40c2bb27e
commit abf0bd2c6b
24 files changed
+1591 -21

No files matched your search

+37
View File
@@ -22,3 +22,40 @@
.submission-card{align-items:flex-start}.submission-data{display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:12px 0 0;font-size:12px}.submission-data dt{font-weight:600;color:var(--muted)}.submission-data dd{margin:0;overflow-wrap:anywhere}.menu-table .check-cell{text-align:center}.menu-table .check-cell input{width:18px;min-height:18px}
.redirect-table{margin-top:26px}.redirect-table td{padding:8px 12px 8px 0;font-size:12px}.redirect-table form{margin:0}
/* Design panel: free values instead of fixed lists. */
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
.design-group>summary::after{content:'+';font-size:17px}
.design-group[open]>summary{border-bottom:1px solid var(--line)}
.design-group[open]>summary::after{content:'–'}
.design-group-body{padding:6px 14px 16px}
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
.design-row{margin-top:14px}
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
.design-row-head label{margin:0;font-size:11px}
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
.color-control .color-text{font-size:12px;min-height:38px}
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
.length-control input,.length-control select{font-size:12px;min-height:38px}
.length-control.is-inherited input{color:var(--muted)}
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
.number-control input[type=number]{font-size:12px;min-height:38px}
.side-wrap{margin-bottom:6px}
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
.design-actions{display:grid;gap:8px;margin-top:22px}
.design-actions button{width:100%}
.theme-form .theme-swatch{width:52px;min-height:38px;padding:2px;cursor:pointer}
.theme-form .weights-cell{white-space:nowrap}
.theme-form .weights-cell label{display:inline-flex;align-items:center;gap:3px;margin:0 8px 0 0;font-size:10px;font-weight:400}
.theme-form .weights-cell input{width:14px;min-height:14px}
+255
View File
@@ -0,0 +1,255 @@
/**
* The design panel.
*
* Every control is generated from the style schema the server sends, so the editor offers exactly
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
* appear here without touching this file.
*/
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
export const GROUPS = {
text: 'Typografie',
background: 'Hintergrund',
spacing: 'Abstand',
size: 'Größe',
border: 'Rahmen',
layout: 'Layout',
effect: 'Wirkung',
};
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch'];
/** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */
function splitLength(value) {
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? '');
if (match) return { number: match[1], unit: match[2] };
return { number: value === '0' ? '0' : '', unit: 'px' };
}
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
export function createDesignPanel({ schema, onChange, getBlock, getDevice, getSelection, getHistory }) {
const change = mutate => { getHistory().change(mutate); onChange(); };
/** One labelled row with a reset button that appears as soon as this device has an own value. */
const row = (id, labelText, control, { own, inherited, onReset }) => {
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
const head = element('div', 'design-row-head');
const label = element('label', null, labelText);
label.htmlFor = id;
head.append(label);
if (own) {
const reset = element('button', 'text-button design-reset-one', '⟲');
reset.type = 'button';
reset.title = 'Auf geerbten Wert zurücksetzen';
reset.setAttribute('aria-label', `${labelText} zurücksetzen`);
reset.addEventListener('click', onReset);
head.append(reset);
}
wrap.append(head, control);
if (!own && inherited) wrap.append(element('p', 'design-inherited', inherited));
return wrap;
};
const colorControl = (id, value, apply) => {
const wrap = element('div', 'color-control');
const picker = document.createElement('input');
picker.type = 'color';
picker.id = id;
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
const text = document.createElement('input');
text.type = 'text';
text.className = 'color-text';
text.value = value ?? '';
text.placeholder = '#1a2b3c';
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
const palette = document.createElement('select');
palette.className = 'color-palette';
palette.setAttribute('aria-label', 'Farbe aus der Palette');
palette.append(new Option('Palette …', ''));
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
palette.value = (value ?? '').startsWith('@') ? value : '';
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
text.addEventListener('change', () => apply(text.value.trim()));
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
wrap.append(picker, text, palette);
return wrap;
};
const lengthControl = (id, value, apply, placeholder) => {
const wrap = element('div', 'length-control');
const parts = splitLength(value);
const number = document.createElement('input');
number.type = 'number';
number.id = id;
number.step = 'any';
number.value = parts.number;
number.placeholder = placeholder ?? 'erbt';
const unit = document.createElement('select');
unit.setAttribute('aria-label', 'Einheit');
for (const name of UNITS) unit.append(new Option(name, name));
unit.value = parts.unit;
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
number.addEventListener('change', push);
unit.addEventListener('change', () => { if (number.value !== '') push(); });
wrap.append(number, unit);
return wrap;
};
const numberControl = (id, value, property, apply) => {
const wrap = element('div', 'number-control');
const slider = document.createElement('input');
slider.type = 'range';
slider.min = property.min; slider.max = property.max; slider.step = property.step;
slider.value = value ?? property.min;
slider.setAttribute('aria-label', `${property.label} Schieberegler`);
const exact = document.createElement('input');
exact.type = 'number';
exact.id = id;
exact.min = property.min; exact.max = property.max; exact.step = property.step;
exact.value = value ?? '';
exact.placeholder = 'erbt';
slider.addEventListener('input', () => { exact.value = slider.value; });
slider.addEventListener('change', () => apply(slider.value));
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
wrap.append(slider, exact);
return wrap;
};
const choiceControl = (id, value, property, apply, inheritedLabel) => {
const select = document.createElement('select');
select.id = id;
select.append(new Option(`Übernehmen: ${inheritedLabel}`, ''));
for (const [key, label] of Object.entries(property.values)) select.append(new Option(label, key));
select.value = value ?? '';
select.addEventListener('change', () => apply(select.value));
return select;
};
const fontControl = (id, value, apply, inheritedLabel) => {
const select = document.createElement('select');
select.id = id;
select.append(new Option(`Übernehmen: ${inheritedLabel}`, ''));
for (const font of schema.fonts) select.append(new Option(`${font.name}${font.family ? ` · ${font.family}` : ''}`, font.key));
select.value = value ?? '';
select.addEventListener('change', () => apply(select.value));
return select;
};
/** Four inputs for padding, margin, border width and corners. */
const sideControl = (key, block, deviceName, property) => {
const grid = element('div', 'side-control');
const parts = property.corners ? schema.corners : schema.sides;
for (const [side, label] of Object.entries(parts)) {
const cell = element('div', 'side-cell');
const id = `style-${key}-${side}`;
const caption = element('label', null, label);
caption.htmlFor = id;
const current = effectiveStyle(block, deviceName, key, side);
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
}, current.value ? String(current.value) : 'erbt');
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
cell.append(caption, control);
grid.append(cell);
}
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
all.type = 'button';
all.addEventListener('click', () => {
const first = grid.querySelector('input[type=number]');
const unit = grid.querySelector('select');
if (first?.value === '') return;
change(doc => {
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
});
});
const wrap = element('div', 'side-wrap');
wrap.append(grid, all);
return wrap;
};
/** Renders the whole panel for one block into the given container. */
return function render(panel) {
const block = getBlock();
const deviceName = getDevice();
if (!block) return;
const scope = element('p', 'design-scope');
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
panel.append(scope);
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
for (const [group, groupLabel] of Object.entries(GROUPS)) {
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
if (properties.length === 0) continue;
const details = element('details', 'design-group');
if (group === 'text' || group === 'spacing') details.open = true;
details.append(element('summary', null, groupLabel));
const body = element('div', 'design-group-body');
for (const [key, property] of properties) {
const id = `style-${key}`;
const current = effectiveStyle(block, deviceName, key);
const own = block.style?.[deviceName]?.[key] !== undefined;
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
const inheritedText = current.value !== undefined && !own
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
: 'Theme-Standard';
let control;
if (property.sides || property.corners) {
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
continue;
}
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
else continue; // media properties are set through the media library
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
if (schema.hoverable.includes(key)) {
const hoverId = `${id}-hover`;
const hoverValue = hoverStyle(block, deviceName, key);
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
const hoverControl = property.type === 'color'
? colorControl(hoverId, hoverValue ?? '', hoverApply)
: property.type === 'number'
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
body.append(row(hoverId, `${property.label} beim Überfahren`, hoverControl, {
own: hoverValue !== undefined,
inherited: 'Kein eigener Hover-Wert',
onReset: () => hoverApply(''),
}));
}
}
details.append(body);
panel.append(details);
}
const actions = element('div', 'design-actions');
const resetDevice = element('button', 'secondary', `${devices[deviceName]} zurücksetzen`);
resetDevice.type = 'button';
resetDevice.addEventListener('click', () => change(doc => resetStyleDevice(doc, getSelection(), deviceName)));
const resetAll = element('button', 'secondary danger-text', 'Gesamte Gestaltung entfernen');
resetAll.type = 'button';
resetAll.addEventListener('click', () => change(doc => clearStyle(doc, getSelection())));
actions.append(resetDevice, resetAll);
panel.append(actions);
};
function describe(property, value) {
if (property.type === 'choice') return property.values[value] ?? value;
return String(value);
}
}
+91 -2
View File
@@ -99,7 +99,7 @@ export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' }
// Called only inside a history transaction: all other fields and IDs stay untouched.
export function upgradeDocument(document, version = 2) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
document.schemaVersion = Math.max(document.schemaVersion, version);
return document;
}
@@ -116,7 +116,7 @@ export function effectiveDesign(block, device, key) {
}
export function setDesign(document, id, device, key, value) {
if (![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
@@ -222,3 +222,92 @@ export class EditorHistory {
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
get dirty() { return JSON.stringify(this.document) !== this.saved; }
}
// ---------------------------------------------------------------------------------------------
// Free block styles (document schema v10).
//
// A style is { device: { property: value, hover: { property: value } } }. Desktop is the base,
// tablet and mobile override it. Everything mirrors StyleSchema on the server: the editor can only
// offer what the server accepts, and a value the server would reject never reaches a document.
// ---------------------------------------------------------------------------------------------
/** The value that actually applies on a device, plus the device it was inherited from. */
export function effectiveStyle(block, device, key, side = null) {
let value, source;
for (const name of Object.keys(devices)) {
const stored = block?.style?.[name]?.[key];
const candidate = side === null ? stored : stored?.[side];
if (candidate !== undefined) { value = candidate; source = name; }
if (name === device) break;
}
return { value, source };
}
export function hoverStyle(block, device, key) {
return block?.style?.[device]?.hover?.[key];
}
/** Removes empty objects so an untouched block never carries a style skeleton. */
function prune(block) {
for (const device of Object.keys(block.style ?? {})) {
const values = block.style[device];
for (const key of Object.keys(values)) {
if (values[key] && typeof values[key] === 'object' && Object.keys(values[key]).length === 0) delete values[key];
}
if (Object.keys(values).length === 0) delete block.style[device];
}
if (block.style && Object.keys(block.style).length === 0) delete block.style;
}
/**
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
* device and finally to the theme. Called inside a history transaction like every other change.
*/
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
const block = locate(document.blocks, id)?.block;
if (!block) return;
const empty = value === '' || value === null || value === undefined;
if (!empty) upgradeDocument(document, 10);
block.style ??= {};
block.style[device] ??= {};
let target = block.style[device];
if (hover) { target.hover ??= {}; target = target.hover; }
if (side !== null) {
if (empty) { if (target[key]) delete target[key][side]; }
else { target[key] ??= {}; target[key][side] = value; }
} else if (empty) {
delete target[key];
} else {
target[key] = value;
}
prune(block);
}
/** Drops every style of one device, so it inherits again. */
export function resetStyleDevice(document, id, device) {
const block = locate(document.blocks, id)?.block;
if (!block?.style) return;
delete block.style[device];
prune(block);
}
/** Drops the whole styling of a block. */
export function clearStyle(document, id) {
const block = locate(document.blocks, id)?.block;
if (!block) return;
delete block.style;
}
/** Copies the styling of one block onto another, with fresh objects. */
export function copyStyle(document, fromId, toId) {
const source = locate(document.blocks, fromId)?.block;
const target = locate(document.blocks, toId)?.block;
if (!source || !target) return false;
if (!source.style) { delete target.style; return true; }
upgradeDocument(document, 10);
target.style = structuredClone(source.style);
return true;
}
+40 -2
View File
@@ -1,5 +1,6 @@
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
import { openMedia } from './media.js';
import { createDesignPanel } from './design-panel.js';
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
@@ -9,7 +10,7 @@ const $ = selector => document.querySelector(selector);
const api = `/admin/api/pages/${encodeURIComponent(root.dataset.pageId)}`;
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
let device = 'desktop', propertyPanel = 'content', designTokens;
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel;
const collapsed = new Set();
function notify(message = '', recover = false) {
@@ -235,6 +236,24 @@ function renderProperties() {
}
function renderDesign(block, panel) {
if (designSchema && designPanel) {
designPanel(panel);
} else {
panel.append(Object.assign(document.createElement('p'), { className: 'panel-note', textContent: 'Gestaltungsoptionen werden geladen …' }));
}
const hasLegacy = Boolean(block.layout?.design) || Boolean(block.layout?.effects);
if (!hasLegacy) return;
const details = document.createElement('details'); details.className = 'design-group';
const summary = document.createElement('summary'); summary.textContent = 'Klassische Werte dieser Seite';
const body = document.createElement('div'); body.className = 'design-group-body';
details.append(summary, body);
renderLegacyDesign(block, body);
panel.append(details);
}
function renderLegacyDesign(block, panel) {
// Token based values from documents written before schema v10. Shown only while a block
// still carries them, so older pages stay editable without cluttering the new panel.
const hint = document.createElement('p'); hint.className = 'design-scope';
hint.textContent = `${devices[device]} · ${device === 'desktop' ? 'Basis für alle Geräte' : device === 'tablet' ? 'bis 1024 px; gilt auch für Mobil' : 'bis 650 px'}`;
const help = document.createElement('p'); help.className = 'field-help'; help.textContent = 'Oben die Vorschaugröße wählen. Ohne eigenen Wert gilt die Einstellung des größeren Geräts oder des Themes.';
@@ -338,6 +357,24 @@ function changed() {
renderTree(); renderProperties(); controls(); schedulePreview();
}
async function loadDesignSchema() {
try {
const response = await fetch('/admin/api/design', { credentials: 'same-origin' });
if (!response.ok) return;
designSchema = await response.json();
designPanel = createDesignPanel({
schema: designSchema,
onChange: () => { controls(); renderTree(); schedulePreview(); renderProperties(); },
getBlock: () => locate(history.document.blocks, selected)?.block,
getDevice: () => device,
getSelection: () => selected,
getHistory: () => history,
});
} catch {
// The editor stays usable without it; the panel says so.
}
}
async function load() {
busy = true; controls();
try {
@@ -347,6 +384,7 @@ async function load() {
if (!response.ok) throw new Error('Designwerkzeuge konnten nicht geladen werden. Bitte erneut versuchen.');
designTokens = await response.json();
}
if (!designSchema) await loadDesignSchema();
history = new EditorHistory(data.document);
({ version, revisionId, publishedRevisionId, csrfToken } = data);
selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); changed(); resizePreview();