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>
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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_lintKIT_RULES). - A second rule test (
FEATURE_RULES) runs over every file inapp/ui/features/: nodocumentorwindow, nostyle,classListorclassName, nocx-orfp-class names, no literal colour or pixel size, no markup, noeval. A feature that needs the page asksapp.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) usesfp-classes and belongs to the shell. Frame-level behaviour, such as the timeline auto-hide (shell/peek.js), lives inshell/; 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
toolbarwraps by default. Add items freely; they flow to a second row instead of widening the zone.segmentedwraps its choices to a second line in a narrow column, so none is clipped.stackis a column with a gap from the scale;region.stackwithfilltakes the room it is given and scrolls inside.panelbodies 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,groupandworkspace, not with ad hoc CSS. - Align on the spacing scale. Related things sit close (
--sp-1,--sp-2), separate groups sit apart (--sp-4and up). - Media grids use
galleryand its density control (internals/density.js, 0 to 4 columns, remembered per gallery id inlocalStorageascx.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;microis 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 throughonChangeand the settled value throughonCommit. 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.jstrap) and hand it back on close. Lists and menus useroving(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 withcloseOnOutside.closeOnEscworks the same way. Do not add your own key handler for this. - Busy. A button doing work shows
busyand 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.jsanddragReorder.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.jsand 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
--textor--muted.--faintis for non-essential detail only. Check both themes. - Reduced motion:
--dur-*become zero andinternals/motion.jschecksprefers-reduced-motion. A new animation must go through them. Do not use a rawtransition: 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
documentorwindowin feature code (useapp.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 testpasses (see Testing); a new element has a demo in the catalogue.