Files
digital-garbageandClaude Sonnet 5.5 d8a906a866 Kit: responsive system (scales, wrapping toolbars, bounded windows), viewport test, docs
Lane sizes and the window margin become tokens; toolbars wrap by default; windows are capped to the viewport; a ratchet test stops element CSS gaining literal pixel sizes. app/e2e runs the real app in Chromium and Firefox at eight screen sizes (npm run test:viewport). docs/ui-design-guidelines.md gains "Responsive by construction".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:54:56 +02:00

15 KiB

UI design guidelines

How FunUI looks and behaves, and why. Read this before you add a screen, a setting or a kit element. For the element list see UI kit reference. For where a feature plugs in see Writing a UI feature.

Principles

  1. Hide, do not warn. If a feature cannot work (its module is missing, the model lacks a trait, it failed to load), it is not drawn. No grey button, no "unavailable" chip. A person never sees a control that cannot be used. The one place that explains absence is the Log.
  2. The screen is the truth. Generate reads the live UI state. A module never reads the UI, and the UI never keeps a second copy of what the project already stores.
  3. Every control that changes the output is visible. Nothing that alters a result hides behind a mode, a tab you did not open or a default you cannot see. Advanced does not mean secret.
  4. Never disrupt input. A value the person is typing is not replaced under their hands. A panel redraw keeps focus and the caret. A toast never steals focus.
  5. Content is data. Every string is shown as text. Markup in a name or a prompt is displayed, never run. The kit has no way to render HTML on purpose.
  6. Say only what is true. A label describes what the control does today. Do not call something "always" if it is a chance, and do not call a trial feature proven.
  7. Calm by default. One primary action per area. Quiet fills, no outlines for the sake of outlines, no row of loud buttons.

Where styling lives

Only the kit styles anything: app/ui/composer/ (tokens, internals, elements). A feature builds with kit elements and names no colour, size or class.

  • A rule test fails the build if kit code parses content as markup or compiles code (tools/kit_lint KIT_RULES).
  • A second rule test (FEATURE_RULES) runs over every file in app/ui/features/: no document or window, no style, classList or className, no cx- or fp- class names, no literal colour or pixel size, no markup, no eval. A feature that needs the page asks app.dom; one that needs a look the kit lacks gets a new kit element (see Adding an element).
  • Modules ship no JavaScript, so there is nothing to scan in modules/.
  • The page frame (app/ui/frame.css, shell/frame.js) uses fp- classes and belongs to the shell. Frame-level behaviour, such as the timeline auto-hide (shell/peek.js), lives in shell/; the feature is only a button that calls it.

Tokens

Never write a literal colour, size, radius, duration or z-index. Use a token. Tokens live in composer/tokens/tokens.css; polish.css is loaded last and retunes some of them (fonts, weights, control heights, card radius), so read the final values in the catalogue (catalogue/index.html), not only in tokens.css.

Family Tokens Use
Type size --fs-1 .. --fs-8, display --fs-d1 .. --fs-d3 Body text is --fs-4. Use the text and header elements instead of setting sizes.
Spacing --sp-0 .. --sp-11 (2px base) Gaps and padding. Pick the step, do not compute pixels.
Radius --r-1 .. --r-5, --r-pill, --card-r Small controls low, cards high, pills for chips.
Control height --ctl-h-sm, --ctl-h-md, --ctl-h-lg Every button, input and select in a row shares one of these.
Motion --dur-1 .. --dur-3, --ease-standard All become zero under reduced motion.
Density --cell-thumb, --cell-card, --cell-panel, --cell-icon, --gutter, --control-col, --menubar-h Media grid cells and layout columns.
Surface --elev-0 .. --elev-3 Page, panel, raised row, floating. Higher is closer to the person.
Lines and states --line, --hover, --active, --focus-ring Hairlines, hover and pressed fills, focus outline.
Text --text, --muted, --faint, --on-accent Main, secondary, tertiary, text on an accent fill.
Meaning --accent*, --teal, --violet, --good, --warn, --danger, --pink See the next section.
Depth --shadow-1 .. --shadow-3, --grad-*, --void Derived. Do not redefine them.

Fonts are bundled (Inter, Inter Tight, IBM Plex Mono) and declared in composer/tokens/fonts.css. Use --font-ui, --font-display and --font-mono. Do not link a font from the web: the app must render offline.

Semantic colour

Colour carries one meaning each. Do not reuse a colour for decoration.

Colour Means
Accent (amber) The one primary action, the selection, the focus of attention.
Teal Rendering and audio.
Violet Learned or taste-driven things.
Good Done, saved, on target.
Warn Needs a look, but nothing is broken.
Danger Failed or destructive.
Muted, faint Help, units, secondary detail.

Never carry meaning by colour alone. A chip says its state in words, an error has text, and a toggle has a visible on position.

Themes

shell/theme.js sets data-theme (dark or light) on <html>. The choice behind it is data-theme-pref (auto, dark, light) and is stored in localStorage under funui_theme. auto follows the system and is resolved before first paint so there is no wrong-theme flash.

  • Token values are scoped to any [data-theme] element, so a single pane can show the other theme (the catalogue does this).
  • Test every new element in both themes in the catalogue.
  • A custom property used but never defined resolves to nothing without any error, so check that every var(--x) you write exists.

Responsive by construction

The kit is built the way a grid system is: you pick a step from a scale, and the layout primitives size and wrap themselves. A new button, field or panel should need no CSS of its own to look right at any window size.

The scales (all in composer/tokens/tokens.css)

Scale Tokens Rule
Spacing --sp-0 .. --sp-11 Every gap and padding. Panels pad with --sp-7 (16px), rows inside a toolbar sit --sp-2 apart.
Control height --ctl-h-sm .. --ctl-h-xl One per row. Never set a height on a button.
Edge room --gutter, --win-margin The space between regions, and the room a window keeps from the screen edge.
Timeline --lane-h-video, --lane-h-audio, --lane-h-overlay, --lane-gutter-w Lane sizes live in one place.

Breakpoints. A custom property cannot be used in @media, so the numbers are written in the rules and listed once in tokens.css: narrow is 1250px and under (the timeline gets taller because its header and toolbar wrap onto more rows), compact is 900px and under. The frame's proportions are in app/ui/frame.css (--media-w, --properties-w, --timeline-h).

Primitives that adapt on their own

  • toolbar wraps by default. Add items freely; they flow to a second row instead of widening the zone.
  • segmented wraps its choices to a second line in a narrow column, so none is clipped.
  • stack is a column with a gap from the scale; region.stack with fill takes the room it is given and scrolls inside.
  • panel bodies pad with the scale and carry no bottom spacer; the padding is the clearance, also at the end of a scroll.
  • Windows (floating.window, modals, popovers) are bounded by the viewport in both axes and come fully back on screen when it shrinks.
  • Settings rows in a stack, a modal or a popover form one card with seams; do not add your own spacing around them.

Checking it. npm test has a ratchet (rule_kit_uses_the_scale.test.js) that fails when element CSS gains a literal pixel size. cd app && npm run test:viewport opens the real app in Chromium and Firefox (whichever are installed) at 1280x720, 1024x1024, 1366x768, 1920x1080, 2560x1440 and the vertical 768x1024, 720x1280, 1080x1920, against a stub server (app/e2e/stub.mjs), and fails on page overflow, a window outside the viewport, or a timeline lane cut off. It needs Playwright browsers, so it is not part of npm test.

Adding an element so it adapts. Use cx- classes and tokens only. Let it be a flex or grid child that can shrink (min-inline-size: 0). Put a row of controls in a toolbar, not in your own flex box. If it needs a size, add a token to tokens.css and use that. Then run the viewport test and look at the element in the catalogue at a narrow width.

Layout and density

  • Build layout with region.stack, splitPane, panel, toolbar, group and workspace, not with ad hoc CSS.
  • Align on the spacing scale. Related things sit close (--sp-1, --sp-2), separate groups sit apart (--sp-4 and up).
  • Media grids use gallery and its density control (internals/density.js, 0 to 4 columns, remembered per gallery id in localStorage as cx.density.<id>, always wrapped in try/catch because storage can be blocked).
  • Text never forces the layout wider. Long names truncate with the full name in the title. Numbers that change use tabular figures.
  • Support narrow windows: a panel scrolls inside itself, the page does not scroll sideways.

Which element for what

You need Use Not
One action button (md; one primary per area) A link styled as a button
An action with only an icon iconButton (the label is required and becomes the tooltip and the accessible name) An icon with no name
An on/off setting toggle, or checkboxRow in a list of options A select with Yes and No
A number number; add slider when the range is small and exploring helps A text box for a number
One of 2 to 4 short options segmented A select
One of many options select; filterList when the list is long A very long segmented row
Short text input textarea
A prompt or notes textarea A one-line input
A colour color.swatch A hex text box
A message that stays banner A toast
A message that fades toast A banner that never leaves
A small state word chip Coloured plain text
Ask before a risky step modal.dialogue with tone: "danger" window.confirm
A list of things list.rows or gallery Hand-made divs
Group of related settings panel, collapsible or settingsRow A bare run of inputs
Wait with no known length progress.indeterminate, spinner, skeleton Frozen controls with no sign

Never call alert, confirm or prompt. They block the page, cannot be themed and cannot be tested.

Interaction

  • Click targets are at least the control height for their size. Dense toolbars use iconButton.sm; micro is for a row that already has its own hit area.
  • Commit rules. Text and number inputs commit on blur or Enter (onCommit). Sliders report live movement through onChange and the settled value through onCommit. Do not start a run or write the project on every keystroke.
  • Focus. The focus ring (--focus-ring) is always visible for keyboard use and is never removed. Overlays trap focus (internals/focus.js trap) and hand it back on close. Lists and menus use roving (arrow keys move, Tab leaves).
  • Escape. Overlays register with the dismiss stack (internals/dismiss.js). Escape closes only the top entry, a click outside dismisses the top entry only if it was opened with closeOnOutside. closeOnEsc works the same way. Do not add your own key handler for this.
  • Busy. A button doing work shows busy and ignores further clicks. It does not change its label to "Please wait".
  • Undo beats confirm when the step can be undone. Confirm only for what cannot.
  • Drag. Dragging uses internals/drag.js and dragReorder.js. Every drag has a keyboard or button way to do the same thing.

Overlays and layers

z-index is written in one place only: internals/zlayer.js. Elements ask for a layer; they never pick a number.

Order, low to high: local, dropline, sticky, menubar, backdrop, wizard, floatingWindow, modal, popover, toast, autocomplete, tour. Each rung owns a block of 100 slots, so peers stack inside a rung and never cross into the next.

  • A modal is above every floating window, because nothing behind it may be used.
  • Autocomplete is above a modal on purpose: it opens from a field that is often inside one.
  • Overlays are drawn into the portal root (internals/portal.js). The portal root must not create a stacking context, or the ladder stops working.
  • An anchored popover positions itself with internals/anchor.js and flips to stay on screen.

Accessibility

  • Every control has an accessible name. Icon-only controls get it from label.
  • State is exposed (pressed, disabled, busy) and not only drawn.
  • Contrast: text on a surface uses --text or --muted. --faint is for non-essential detail only. Check both themes.
  • Reduced motion: --dur-* become zero and internals/motion.js checks prefers-reduced-motion. A new animation must go through them. Do not use a raw transition: 200ms.
  • Everything reachable by mouse is reachable by keyboard.

Words in the UI

Dex's rule is plain words. Apply it to every label, hint, error and toast.

  • Short and plain. A label is a noun or a short verb phrase ("Steps", "Save project").
  • A hint is one sentence that says what the setting does for the result, not how it works inside ("Keeps the first picture's look through the clip").
  • Sentence case. No ending full stop on a label. A hint ends with one.
  • A plain hyphen. No em dash or en dash anywhere in the repo, docs and code strings included. Existing code comments still contain some; do not add more.
  • An error says what happened and what to do next ("The seed must be a whole number"). It never shows a stack trace or an internal name.
  • Do not promise what is only likely. Not "always", not "guaranteed". Say "usually" or describe the setting.
  • Do not describe a feature by its mechanism first. Lead with what the person gets.
  • Use the same word for the same thing everywhere. If the app says "Project", do not say "Session" in a hint.
  • No jargon a person would need a manual for. If a technical word is unavoidable, the hint explains it in a few plain words.

Checklist before you open a pull request

  • Only kit elements; no literal colour, size or z-index; no document or window in feature code (use app.dom).
  • Every string is passed as text. A name containing <b> shows as <b>.
  • Looks right in dark and light, at a narrow width, with reduced motion on.
  • Usable by keyboard alone; Escape closes the top overlay only.
  • If the feature cannot load, nothing is drawn and the Log says why.
  • Text follows the word rules above, and has no long dashes.
  • Nothing a person is typing is overwritten by a redraw.
  • npm test passes (see Testing); a new element has a demo in the catalogue.