Files
KantSites/public/assets/design-panel.js
T
kantarciandClaude Opus 5 abf0bd2c6b 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>
2026-09-12 13:04:54 +02:00

256 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
}
}