---
version: 0.0.1
name: atomm Generator UI
description: The design system for third-party generator apps embedded in the atomm platform. Hand this file to your coding agent.
colors:
  primary: '{colors.neutral-900}'
  neutral-0: '#ffffff'
  neutral-25: '#fbfcfd'
  neutral-50: '#f9fafb'
  neutral-100: '#f3f4f6'
  neutral-200: '#e5e7eb'
  neutral-300: '#d1d5db'
  neutral-400: '#9ca3af'
  neutral-500: '#6b7280'
  neutral-600: '#4b5563'
  neutral-700: '#374151'
  neutral-800: '#1f2937'
  neutral-850: '#18202f'
  neutral-900: '#111827'
  ink-alpha-5: rgba(17,24,39,0.05)
  ink-alpha-8: rgba(17,24,39,0.08)
  ink-alpha-45: rgba(17,24,39,0.045)
  scrim-44: rgba(17,24,39,0.44)
  blue-50: '#f0f3ff'
  blue-500: '#2661f1'
  blue-600: '#0050f4'
  green-50: '#ecfff2'
  green-500: '#00c767'
  orange-50: '#fcf3ed'
  orange-500: '#ff7c23'
  red-50: '#fff2f3'
  red-500: '#fa2730'
  crimson-600: '#d9002d'
  crimson-700: '#b20025'
  crimson-800: '#8c001d'
  ruler-major: '#c2c8d0'
  ruler-minor: '#e2e6ea'
  brand-default: '{colors.neutral-900}'
  brand-hover: '{colors.neutral-800}'
  brand-active: '{colors.neutral-850}'
  on-primary-button: '{colors.neutral-0}'
  emphasize-default: '{colors.crimson-600}'
  emphasize-hover: '{colors.crimson-700}'
  emphasize-pressed: '{colors.crimson-800}'
  on-emphasize: '{colors.neutral-0}'
  text-primary: '{colors.neutral-900}'
  text-secondary: '{colors.neutral-600}'
  text-tertiary: '{colors.neutral-500}'
  text-disabled: '{colors.neutral-400}'
  stroke-divider: '{colors.ink-alpha-8}'
  stroke-default: '{colors.neutral-200}'
  stroke-input: '{colors.neutral-300}'
  stroke-strong: '{colors.neutral-400}'
  surface-default: '{colors.neutral-0}'
  surface-subtle: '{colors.neutral-50}'
  surface-elevated: '{colors.neutral-25}'
  canvas-surface: '{colors.neutral-25}'
  canvas-grid: '{colors.ink-alpha-45}'
  ruler-label: '{colors.neutral-400}'
  background-input: '{colors.neutral-100}'
  background-hover: '{colors.ink-alpha-5}'
  background-active: '{colors.ink-alpha-8}'
  background-scrim: '{colors.scrim-44}'
  track-off: '{colors.neutral-300}'
  focus-ring: '{colors.blue-600}'
  info: '{colors.blue-500}'
  info-subtle: '{colors.blue-50}'
  success: '{colors.green-500}'
  success-subtle: '{colors.green-50}'
  warning: '{colors.orange-500}'
  warning-subtle: '{colors.orange-50}'
  error: '{colors.red-500}'
  error-subtle: '{colors.red-50}'
  link-default: '{colors.blue-500}'
typography:
  headline-md:
    fontFamily: Inter
    fontSize: 28px
    fontWeight: 600
    lineHeight: 36px
    letterSpacing: -0.5px
  headline-sm:
    fontFamily: Inter
    fontSize: 22px
    fontWeight: 500
    lineHeight: 26px
  title:
    fontFamily: Inter
    fontSize: 16px
    fontWeight: 600
    lineHeight: 22px
  field:
    fontFamily: Inter
    fontSize: 14px
    fontWeight: 400
    lineHeight: 20px
  field-strong:
    fontFamily: Inter
    fontSize: 14px
    fontWeight: 500
    lineHeight: 20px
  label:
    fontFamily: Inter
    fontSize: 14px
    fontWeight: 600
    lineHeight: 20px
  note:
    fontFamily: Inter
    fontSize: 12px
    fontWeight: 400
    lineHeight: 18px
  micro:
    fontFamily: Inter
    fontSize: 12px
    fontWeight: 400
    lineHeight: 16px
  label-micro:
    fontFamily: Inter
    fontSize: 12px
    fontWeight: 600
    lineHeight: 12px
  ruler:
    fontFamily: Inter
    fontSize: 9px
    fontWeight: 400
rounded:
  sm: 6px
  md: 8px
  lg: 10px
  xl: 12px
  full: 9999px
spacing:
  xs: 4px
  sm: 6px
  md: 8px
  lg: 12px
  xl: 16px
  section: 24px
  gutter: 16px
  min-target: 24px
sizes:
  control-mini: 24px
  control-sm: 28px
  control-md: 32px
  control-lg: 40px
  input: 40px
  chip: 32px
  chip-w-sm: 48px
  chip-w: 64px
  chip-w-lg: 80px
  rail-lead: 320px
  rail-params: 272px
  rail-collapsed: 40px
  ruler: 20px
elevation:
  subtle: rgba(17,24,39,0.10) 0 8px 28px
  micro: rgba(17,24,39,0.08) 0 1px 3px
  knob: rgba(17,24,39,0.20) 0 1px 2px
focus:
  ring-color: '{colors.focus-ring}'
  ring-width: 2px
  ring-offset: 2px
motion:
  fast: 150ms
  base: 200ms
  easing: cubic-bezier(0.2, 0, 0.38, 1)
components:
  button-primary:
    backgroundColor: '{colors.brand-default}'
    textColor: '{colors.on-primary-button}'
    typography: '{typography.label}'
    rounded: '{rounded.md}'
    height: 32px
    padding: 0 12px
  button-secondary:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-input}'
    textColor: '{colors.text-primary}'
    rounded: '{rounded.md}'
    height: 32px
  button-ghost:
    textColor: '{colors.text-secondary}'
    rounded: '{rounded.md}'
    height: 32px
  button-emphasize:
    backgroundColor: '{colors.emphasize-default}'
    textColor: '{colors.on-emphasize}'
    rounded: '{rounded.lg}'
    height: 40px
  input-field:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-input}'
    textColor: '{colors.text-primary}'
    typography: '{typography.field}'
    rounded: '{rounded.md}'
    height: 40px
    padding: 0 12px
  input-field-hover:
    borderColor: '{colors.stroke-strong}'
  input-field-focus:
    borderColor: '{colors.brand-default}'
  value-chip:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-input}'
    typography: '{typography.field-strong}'
    rounded: '{rounded.md}'
    height: 32px
    padding: 0 10px
    textAlign: end
  select-trigger:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-input}'
    rounded: '{rounded.md}'
    height: 40px
    padding: 0 10px 0 12px
  select-list:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-default}'
    rounded: '{rounded.lg}'
    padding: 6px
  select-option:
    height: 32px
    padding: 0 10px
    rounded: '{rounded.sm}'
  select-option-selected:
    backgroundColor: '{colors.background-active}'
  switch-track:
    backgroundColor: '{colors.neutral-300}'
    rounded: '{rounded.full}'
    width: 40px
    height: 24px
  switch-track-on:
    backgroundColor: '{colors.brand-default}'
  switch-knob:
    backgroundColor: '{colors.surface-default}'
    rounded: '{rounded.full}'
    size: 20px
  slider-track:
    backgroundColor: '{colors.track-off}'
    rounded: '{rounded.full}'
    height: 6px
  slider-track-filled:
    backgroundColor: '{colors.brand-default}'
  slider-thumb:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-strong}'
    rounded: '{rounded.full}'
    size: 18px
  segmented-track:
    backgroundColor: '{colors.background-input}'
    rounded: '{rounded.lg}'
    padding: 3px
  segmented-item-active:
    backgroundColor: '{colors.surface-default}'
    rounded: '{rounded.md}'
    height: 28px
  float-panel:
    backgroundColor: '{colors.surface-default}'
    borderColor: '{colors.stroke-default}'
    rounded: '{rounded.xl}'
    padding: 6px
  alert:
    typography: '{typography.note}'
    textColor: '{colors.text-primary}'
    rounded: '{rounded.md}'
    padding: 12px
  dialog:
    backgroundColor: '{colors.surface-default}'
    rounded: '{rounded.xl}'
    padding: 24px
    width: 400px
  button-primary-hover:
    backgroundColor: '{colors.brand-hover}'
  button-primary-active:
    backgroundColor: '{colors.brand-active}'
  button-secondary-hover:
    backgroundColor: '{colors.background-hover}'
  button-ghost-hover:
    backgroundColor: '{colors.background-hover}'
  button-emphasize-hover:
    backgroundColor: '{colors.emphasize-hover}'
  button-emphasize-active:
    backgroundColor: '{colors.emphasize-pressed}'
  button-disabled:
    backgroundColor: '{colors.surface-subtle}'
  input-field-disabled:
    backgroundColor: '{colors.surface-subtle}'
  helper-text:
    textColor: '{colors.text-tertiary}'
    typography: '{typography.note}'
  link:
    textColor: '{colors.link-default}'
    typography: '{typography.field}'
  panel:
    backgroundColor: '{colors.surface-default}'
    textColor: '{colors.text-primary}'
  panel-elevated:
    backgroundColor: '{colors.surface-elevated}'
    rounded: '{rounded.xl}'
  page:
    backgroundColor: '{colors.surface-subtle}'
    textColor: '{colors.text-primary}'
  canvas:
    backgroundColor: '{colors.canvas-surface}'
  canvas-gridline:
    backgroundColor: '{colors.canvas-grid}'
  ruler:
    textColor: '{colors.ruler-label}'
    typography: '{typography.ruler}'
  ruler-tick-major:
    backgroundColor: '{colors.ruler-major}'
  ruler-tick-minor:
    backgroundColor: '{colors.ruler-minor}'
  dialog-scrim:
    backgroundColor: '{colors.background-scrim}'
  alert-info:
    backgroundColor: '{colors.info-subtle}'
    textColor: '{colors.text-primary}'
    typography: '{typography.note}'
    rounded: '{rounded.md}'
  alert-info-icon:
    textColor: '{colors.info}'
  alert-success:
    backgroundColor: '{colors.success-subtle}'
    textColor: '{colors.text-primary}'
    typography: '{typography.note}'
    rounded: '{rounded.md}'
  alert-success-icon:
    textColor: '{colors.success}'
  alert-warning:
    backgroundColor: '{colors.warning-subtle}'
    textColor: '{colors.text-primary}'
    typography: '{typography.note}'
    rounded: '{rounded.md}'
  alert-warning-icon:
    textColor: '{colors.warning}'
  alert-error:
    backgroundColor: '{colors.error-subtle}'
    textColor: '{colors.text-primary}'
    typography: '{typography.note}'
    rounded: '{rounded.md}'
  alert-error-icon:
    textColor: '{colors.error}'
  field-label:
    textColor: '{colors.text-secondary}'
    typography: '{typography.field}'
  button-disabled-label:
    textColor: '{colors.text-disabled}'
---

# atomm Generator UI

## Overview

A generator is a **workbench, not a storefront**. The reference is the properties panel of a professional creative tool — the right rail of a photo editor, the parameter stack of a 3D application — where the chrome is monochrome and unremarkable so that the thing being made is the only coloured object on screen.

The user is in the middle of work. They came to produce an artifact, not to be impressed. They will run the same generator twenty times in an afternoon, changing one value each time. The interface earns its keep through predictability at that repetition rate: controls always in the same place, values always legible, the twentieth transition identical to the first.

The register is **quiet and open**, not dense. Reading "dense" as darker and heavier is tiring in practice, so this system does the opposite: **hierarchy is built by removing weight, not adding it**. Labels recede into a lighter, secondary colour; only values, the primary button and the artifact itself are dark. One red is held in reserve for the single most consequential action on screen.

Your generator runs inside the atomm platform, in the region the platform allocates to it. It lives *inside* someone else's product, adjacent to chrome you do not control. Design like a well-behaved tenant: match the host's visual language, never imitate the host's navigation, and never try to cover it.

What it is not: not a marketing page, not a dashboard, not a consumer app. There is no hero moment, no gradient, no glass, no glow, no pill-shaped button, no illustrated empty state apologising for itself, and no entrance animation on load. Those belong to products that must first convince you to stay. This one already has your attention.

## Colors

The palette is written in two layers. **Primitives** hold the raw values and are the only place a hex appears. **Semantic tokens** reference them and say what a colour is *for*. Components reference the semantic layer, never a primitive. That is what makes dark mode a change to thirteen neutrals rather than a change to every component.

### The neutral ramp: consistent coolness reads as clean, leftover chroma reads as dirty

Every step of this ramp leans cool, with an RGB spread reaching 19–26 in the mid tones. This is **deliberate**, not an unfinished correction.

A "de-blued" neutral ramp was measured (spread compressed to 6–8, luminance unchanged) and it read as dirty — because the residual chroma landed in the ambiguous band: not neutral enough to read as grey, not cool enough to read as cool grey. **Either commit to cool or go fully neutral; the middle ground is the worst of the three.**

The one exception is the **ruler ticks**, which use a purely neutral grey. Instrument markings and interface stand on their own terms: the interface is uniformly cool, the ticks are neutral, and neither disturbs the other.

### Four text steps, each roughly half the one above

| Token | Value | Contrast on white | Used for |
|---|---|---|---|
| `text-primary` | `neutral-900` | **17.74:1** | Values, body copy, section titles, statistics |
| `text-secondary` | `neutral-600` | **7.56:1** | Field labels, switch labels, secondary button labels, icon buttons |
| `text-tertiary` | `neutral-500` | **4.83:1** | Explanatory copy, unit suffixes, timestamps |
| `text-disabled` | `neutral-400` | 2.54:1 | Disabled |

A ladder such as 19.7 / 12.4 / 3.4 / 2.0 fails on both counts: the first two steps are too close to tell apart, the second-to-third step is a cliff, and three of the four sit bunched at the dark end. "Make the label recede" then means choosing between too dark and too light. All four steps here are distinguishable from each other, which is the precondition for hierarchy being readable at all.

Explanatory copy went from 3.4:1 to **4.83:1**, clearing the 4.5 required for body text for the first time.

### Three stroke levels with distinct jobs

- **`stroke-divider`** (`rgba(17,24,39,0.08)`) between items inside one surface: between sections, under a panel header, between statistic blocks. The weakest step.
- **`stroke-default`** (`neutral-200`) the boundary of floating surfaces and cards: dropdown popovers, canvas overlays, tooltips, the outline of the sheet.
- **`stroke-input`** (`neutral-300`) inputs, value chips, select triggers, secondary buttons. The boundary of an interactive control sits one step heavier than a decorative one.

**Hover darkens the border rather than lightening the fill.** `surface-subtle` and `background-input` are very close in light mode, so changing the fill often reads as no change at all; lifting the border from `stroke-input` to `stroke-strong` is always visible.

### Canvas and rulers

The canvas surface is `canvas-surface` (`neutral-25`, near-white). It sits only one step below the panel white — **the canvas must not be darker than the panels**. A dark canvas pulls attention away from the artifact and contradicts the premise that the artifact is the only loud thing on screen.

Separation comes from two things: a `stroke-divider` cutting the rails away from the canvas, plus the sheet's own `stroke-default` outline and `elevation.micro` contact shadow.

The millimetre grid uses `canvas-grid` (4.5% ink), weaker than the divider. The ruler is three descending layers of instrument chrome:

| Layer | Token | On canvas |
|---|---|---|
| Numerals | `ruler-label` | 2.47:1 |
| Major ticks | `ruler-major` | 1.64:1 |
| Minor ticks | `ruler-minor` | 1.22:1 |

Against the artifact's own 19.2:1, this ladder means exactly one thing: **visible when you look for it, out of the way when you don't**.

### Brand and emphasize

- **Brand** `brand-default` is `neutral-900`, **one step darker than body text**. It carries primary buttons, the filled portion of a slider, and a switch in its on state. Those elements should outweigh text, or the hierarchy is inverted.
- **Emphasize** `emphasize-default` is a saturated red that appears **at most once per screen**, on the action that costs money, spends credits, or cannot be undone. Its power comes entirely from scarcity. A screen with two red buttons has none.
- **Semantic colours** work in pairs: the tint is the fill, the solid is the **icon**, and the message text stays `text-primary`. Coloured text on a tint is the most common accessibility mistake in status messaging.
- **Focus** `focus-ring` is a solid blue, not a tint. It is frequently the only indication of where the keyboard is, so it has to clear 3:1 on its own.

## Typography

**Inter** throughout, three weights, with line heights in even pixels rather than unitless multipliers — the vertical rhythm has to survive being nested inside dense control rows, and a multiplier drifts as the size changes.

### Three registers, not one size ladder

| Register | Value | Carries |
|---|---|---|
| `field` / `field-strong` / `label` | **14 / 20** | Field labels, values, button labels, section titles, statistics |
| `note` | **12 / 18** | Explanatory copy, alerts, dialog body |
| `micro` | 12 / 16 | Unit suffixes, timestamps, 24px dense controls |
| `ruler` | 9 | Ruler ticks only |

**Structure and explanation are two registers and should not be raised together.** 14px is the baseline for structure and values; 12px is not "one size smaller than 14", it is the level for paragraphs — with 18px leading rather than 16, precisely because four lines of 12px at 16px leading suffocate.

Scaling the whole system down means changing two values, `--fs-field` and `--fs-note`.

### Hierarchy from weight and colour, not size

Three layers inside one 14px size:

- **Section title** 14 / 500 / `text-primary`
- **Value** 14 / 500 / `text-primary` (tabular figures use `tabular-nums`)
- **Field label** 14 / 400 / `text-secondary`

Field label and value differ by **two steps: weight and colour**. That is clearer than adding another size and it does not make the panel taller. Nine labels at 500 weight in the primary text colour stacked down one column are all shouting, and nothing stands out.

`title` (16 / 600) is used in exactly one place, the app bar heading. It has to sit one step above the field labels or every word in the panel looks the same size. The 9px `ruler` is for ruler ticks and nothing else.

## Layout

**Canvas first.** The canvas is not a card, it is the full-bleed base layer; the two rails sit against it, **each with one job**.

```
App bar (full width, white)
├── Leading rail 320 ─┬── Canvas (takes everything else) ──┬── Trailing rail 272
    What to add        │  Near-white, top/left mm rulers    │  What to adjust
    Templates/presets  │  Artifact + dimension annotations  │  Sections + field rows
    Assets/layers      │  Three overlays                    │  ───────────────────
    White, scrolls     │                                    │  Derived results + export (pinned)
```

**The leading rail answers "what goes on the canvas"** — templates, presets, assets, layers. Its content is a set of interchangeable items, so it uses thumbnails or lists with an unambiguous selected state.

**The trailing rail answers "what does the thing on it look like"** — the property stack for the current object. Its content is sections and field rows, and **export is pinned to its bottom** so nobody has to scroll to find it.

This division makes the two rails **non-interchangeable**: adding is discrete selection, adjusting is continuous. Mixed into one rail, the user re-locates themselves between thumbnails and sliders on every single change.

**Derived results** (usage, duration, part count) do not get their own column — they sit with export at the bottom of the trailing rail, or are drawn on the canvas as dimension annotations.

Zero gaps, zero radii between the three regions. Rail widths are **fixed** and the canvas absorbs window resizing — a parameter rail should not get wider because the window did. Both rails collapse to a 40px sliver; collapsing the trailing rail grows the canvas, and collapsing the leading rail suits the phase after a template has been chosen and the work is pure parameter tuning.

**A generator with no content library** (parameters only, nothing to pick) **drops the leading rail entirely**. Two regions is enough — an empty extra panel is worse than one fewer.

The layout has to make this flow obvious: **input → configure → generate → review → refine → export**. Not every generator has every step. Live-preview generators — the kind that redraw as a slider moves — **have no generate step at all**, and adding a generate button to one is a defect, not a courtesy.

### What goes on the canvas

- **Top-left corner plus the top and left edges: mm rulers**, with zero aligned to the artboard origin. Ticks step through 1/2/5/10/20/50/100 and major ticks are never closer than 80px, so the numerals never collide. The background grid is **synchronised with the minor ticks** and the two stay locked at every zoom level — a "5mm grid with a 10mm ruler" mismatch must never occur.
- **Top-right overlay**: actions on the artifact (regenerate, compare with previous).
- **Bottom-left overlay**: display-mode segmented control plus view aids (reference image).
- **Bottom-right overlay**: the zoom cluster.

Overlays use `float-panel`: a `stroke-default` border plus `elevation.micro`, and **no displacement on hover**. A card that rises under the cursor is a consumer-app gesture and reads as fidgety in a tool.

### All three preview states need designing

- **Empty**: show examples or recent work. A blank rectangle reads as broken.
- **Computing**: stage name + numbers + a way to cancel. Never an indefinite spinner — a spinner with personality is what you build when you do not know the progress.
- **Done**: zoom, pan, compare, regenerate.

**Display modes must be honest.** If the preview thickens lines so they can be seen, there has to be a "what you see is what exports" mode and the difference between the two has to be explainable. A 0.1mm line really is a hair on screen; that is correct, not broken.

Errors belong beside the control that failed, saying why and offering the next action; a toast at the top of the screen is where errors go to be missed.

### Vertical rhythm

One ladder inside a panel, values taken from the scale:

```
divider ── 24 ── section title ── 12 ── field ── 16 ── field ── 16 ── divider
                                  label ── 4 ── control ── 4 ── note
```

The contrast between 4 and 16 makes the note unambiguously belong to the control above it; 12 < 16 makes the title unambiguously belong to the group below. **Panel gutters are a uniform 16px** (`spacing.gutter`).

If a layout only looks right at 14px, the problem is the alignment, not the scale.

### Group with a line, not a box

The panel is already a closed region; wrapping cards inside it draws the boundary a **second time**, and every card radius competes with the input radii for attention. Separate sections with a single `stroke-divider`.

The only exceptions are things that genuinely leave the plane: overlays, dropdown popovers, dialogs. They need a border plus a contact shadow to explain where they are floating.

### Sizing and scrolling

**Your width is the region the platform allocates, not the viewport.** Panels adapt with container queries, not media queries. Below 960px, switch to a step flow (bottom tabs for configure / canvas / results) and pin the generate action to the bottom; the vertical dividers have to go at that point, or they become two meaningless edges.

Scroll containers use thin scrollbars (`scrollbar-width: thin` plus an 8px custom thumb). A classic scrollbar eats 15px out of the padding and makes the right side of a panel look twice as wide as the left.

Never introduce horizontal scrolling, never nest a scroll container inside another, and never let a sticky element crowd out the content it is anchored to.

**Panels must be `flex: 1`.** A container with only `min-height: 0` is as tall as its content — which is what leaves the preview short and lets the configuration rail overflow instead of scrolling internally.

## Elevation & Depth

Depth comes from **surface tone and borders first, shadow second**. Three shadows; a fourth is where the design system starts to leak:

| Token | Value | Used for |
|---|---|---|
| `elevation.subtle` | `rgba(17,24,39,0.10) 0 8px 28px` | Dialogs, dropdown popovers |
| `elevation.micro` | `rgba(17,24,39,0.08) 0 1px 3px` | Canvas overlays, the sheet, the active segment |
| `elevation.knob` | `rgba(17,24,39,0.20) 0 1px 2px` | Slider thumb, switch knob |

`knob` exists for a specific reason: **a white disc on a light track dissolves under `micro` alone**. The knob is the element that carries a value through its position, so dissolving means the value cannot be read. Do not substitute `micro`, and do not fix the knob by making `micro` heavier overall — that dirties every overlay.

No shadow is ever animated on hover.

Keyboard focus is its own layer and does not participate in elevation:

The focus ring takes `colors.focus-ring` at 2px width and 2px offset (see the `focus` group in the front matter).

That offset is load-bearing, not decorative: it separates the ring from the control's own fill so the ring stays visible on a near-black button as readily as on a white one. Setting it to 0 is exactly why focus rings disappear on primary buttons.

**The input family is the exception to the focus ring** — inputs, value chips and select triggers show focus by moving the border from `stroke-input` to `brand-default`. They already have a border, so adding a ring means two boundaries.

## Shapes

Four rounded-rectangle steps, with radius following height: a 24px control takes `rounded.sm`, 28 and 32 take `rounded.md`, 40 takes `rounded.lg`, and cards, dialogs and overlays take `rounded.xl`. The pairing matters more than the individual numbers — a 40px control at 6px looks unfinished, and the same control at 20px looks like a different product.

**Primary controls are never pill-shaped.** Even the largest button caps at `rounded.lg`.

`rounded.full` is reserved for true circles and discs: status dots, the slider thumb, the switch knob. **The switch track is the single capsule exception in the system** — a 40×24 track at half-height radius — and it earns that exception because a rounded-rectangle switch reads as a checkbox.

Corners are the only shape device. No angled cuts, no notches, no decorative borders, no gradient strokes.

## Components

Controls are built on four heights — 24, 28, 32, 40 — with radius, inline padding and label size all following the height. **32 is the default for buttons, 40 is the default for the input family**, and 24 is for dense toolbars.

Every interactive element supports the same closed state set: default, hover, active, focus-visible, disabled, loading, selected, error. There is no other state. A control that needs a new one is a control that needs rethinking.

### The input family: white fill plus a border

`input-field` is 40px, white, 1px `stroke-input`, `rounded.md`. Hover lifts the border to `stroke-strong`, focus lifts it to `brand-default`. Disabled swaps to a `surface-subtle` fill with a `stroke-default` border. Error keeps the fill and takes a 1px `error` border, with the message beside the field rather than only in a toast.

**Grey-fill borderless inputs are retired.** White plus a border says "you can type here" more clearly, and it frees `background-input` to serve value chips and segmented tracks exclusively.

### Value chips: one shape, four widths

Numeric parameters with units are always one row of `label ⋯⋯ [value]`, with the chip aligned to the end.

**What is unified is the shape, not the width.** The shape — white fill, `stroke-input` border, `rounded.md`, 32 tall, end-aligned, 500 weight, `tabular-nums` — must be identical everywhere; the width steps with content:

| Step | Value | Composition | Used for |
|---|---|---|---|
| `chip-w-sm` | 48px | 10 + two digits 17 + 10 | Plain integers, no unit |
| `chip-w` | 64px | 10 + 17 + unit 26–34 | Two digits plus `%` or `mm` |
| `chip-w-lg` | 80px | 10 + three digits 25 + shared slot 32 | With a drag handle |
| Custom | — | By digit count | Six digits or more, e.g. a seed |

One width for everything is wrong: the real visual anchor is the **end edge**, and the layout already aligns that; a ragged start edge is unremarkable, whereas a "6" sitting in a 104px box is not. Keep the slack in each step under 13px.

**The unit suffix and the drag handle share one slot.** Reserving a slot for each leaves 24px of permanent emptiness to the end of every chip, since the handle is invisible until hover. At rest the unit shows; on hover the unit fades out and the handle fades in — you do not need to read the unit at the moment you are about to drag.

### Horizontal dragging on numeric fields

Native up/down spinners are never used: the targets are tiny and the register is wrong for a tool panel. Instead, a 24×24 horizontal drag handle sits at the end of the field, fading in over 150ms on hover or focus, in a fixed position — the padding is reserved at all times so its appearance never pushes the digits.

- Press and drag to change the value, with sensitivity set per field (3px per step for artboard dimensions, 2px per step for a seed).
- Shift for ten-times steps, Alt for fine adjustment.
- A movement under 3px counts as a click → focus and select all.
- `setPointerCapture`, so dragging outside the panel does not drop the gesture.
- In RTL, dragging toward the start edge increases the value.
- Removing the native spinner removes coarse keyboard adjustment, so **Shift + ↑/↓ = ten steps** must be added back.
- The handle is `aria-hidden` and out of the tab order — it is a redundant pointer-side entry point, typing and arrow keys already exist, and labelling it only makes a screen reader announce the same operation twice.
- Touch devices have no hover, so the handle is always visible there.

**No handle on a row that already has a slider.** Two drag entry points compete for the same job.

### Select: a hand-built listbox

A native `<select>` cannot restyle its popup, so a slab of system UI drops out of the tool panel. Hence the hand-built version — and the price is **repaying the full keyboard and screen-reader contract**, which is not optional:

- `role="combobox"` / `role="listbox"` / `role="option"`, `aria-expanded`, `aria-selected`, `aria-activedescendant`
- Use the ARIA 1.2 **select-only combobox** pattern: focus stays on the trigger throughout and `aria-activedescendant` points at the highlighted option, with **no focus movement** (moving focus makes it far easier to strand focus on `body` when closing)
- Closed: ↑↓ / Enter / Space / Home / End / any character opens it
- Open: ↑↓ moves, Home/End jump to the ends, Enter/Space selects and closes, Escape closes without changing the value, Tab closes, an outside click closes, type-ahead works
- Focus returns to the trigger on close
- Mount the popover on `body` with `position: fixed` — left in place it gets clipped by scroll containers and `overflow: hidden`. Flip it upward when space runs out and clamp it inside the viewport. In RTL, align it to the trigger's **end** edge.
- Keep a native `<select>` in the HTML as the no-script fallback, styled to match the trigger.

The selected state is `background-active` **plus a checkmark**. Fill alone is too weak to distinguish a selection in a four-item list, and colour cannot carry meaning by itself.

**The mobile cost has to be acknowledged**: you give up the system picker (the iOS wheel, the Android native list), and 32px options are small on a touch screen. If your generator sees heavy phone use, fall back to a native `<select>` at narrow breakpoints.

### Switch

A 40×24 track, `neutral-300` when off and `brand-default` when on; a 20px white knob with `elevation.knob`.

It is the most frequently broken control in this system, and it always breaks the same way: the track and knob are decoration painted over a real `<input type="checkbox">`, and if any decorative layer forgets `pointer-events: none` it swallows the click, leaving a switch that looks perfect and does nothing. The 24px track height is exactly the minimum target size, but wrapping the whole thing in its label is still recommended.

### Slider

The workhorse of parameter-driven generators, and **never used alone**. The current value always appears as a number at the end of the row — a thumb position is not a value. When precision matters (steps, seed, dimensions) pair it with a numeric input, because a slider cannot be typed into. Step and range come from the model's real constraints rather than from what feels smooth; a silently clamped value is a bug the user cannot see.

Three numbers are load-bearing; do not reduce them:

- **A 6px track**, with the unfilled portion in `track-off` (`neutral-300`). `background-input` measures **1.10:1** on white, which is to say it does not exist — that is not "the right half of the track", it is blank space, and the thumb appears to hang in mid-air. A 1.47:1 hairline is invisible; a 1.47:1 six-pixel band is not.
- **An 18px thumb**, white with a 1px `stroke-strong` border and `elevation.knob`. The border is not decoration: a white disc on a light track dissolves under shadow alone. On hover and keyboard focus the border turns `brand-default`.
- **12:1 between filled and unfilled**, so the position reads at a glance.

Disabled is a `neutral-200` track with a `neutral-100` thumb and a `stroke-input` border — visibly "here but not movable", rather than gone.

### Buttons

Four variants. `button-primary` is near-black and answers most questions. `button-secondary` is a white shell with a `stroke-input` border; on hover the border darkens and the fill lightens slightly. `button-ghost` is transparent until hovered. `button-emphasize` is the red one, at most one per screen, usually at the 40px step.

Destructive actions use the secondary shell with `error` text plus a confirmation step — never a solid red fill. A large red Delete invites exactly the misclick it exists to prevent.

Button labels are verbs: Generate, Export, Regenerate. Not OK, not Submit. The loading state keeps its width and its label, swapping only the leading icon for a spinner and setting `aria-busy`.

### Segmented control

A grey track (`background-input`) with 3px padding and `rounded.lg`; the active segment is white with `rounded.md` and `elevation.micro`. Use it for two or three mutually exclusive view modes. It is not a tab strip and does not carry page navigation.

### Section headers

The whole row is a `<button aria-expanded>`: the title text at the start, the disclosure arrow at the **end of the row**.

Putting the arrow at the end lets the title text align with the field labels on one line and the arrow align with the value chips on another. The arrow points **up/down** (up when expanded) rather than left/right, because vertical arrows do not participate in RTL mirroring, which removes a special case. Collapsing also removes the 12px below the title.

Only `hidden` and one `transform` change; there is no height animation.

### Alerts, links, helper text

**Alerts** put the tint behind the message and the solid on the icon, with `text-primary` on the words, placed next to whatever they describe. **Links** take `link-default` and keep their underline — colour alone is not a link affordance. **Helper text** under a field is `note` in `text-tertiary`, and it explains a constraint rather than restating the label.

**Be stingy with explanatory copy.** Anything that fits in a unit suffix should not become a sentence — `20 %` beats "percentage of a single tile's edge length". Anything readable from the results panel should not be repeated in the configuration panel. The ideal end state is a configuration panel with two or three genuinely constraining notes left in it.

### Dialogs

Modals **inside your panel**. An overlay cannot cover the platform chrome and cannot extend past the region your app occupies. Use the native `<dialog>` with `showModal()` — role, backdrop and focus behaviour come with it. Widths are 400 for a confirmation, 560 for a form, 720 for a comparison. Most things put in dialogs do not belong there: errors go beside their control, progress goes in the preview, settings go in the configuration panel.

## Motion

| Token | Value |
|---|---|
| `motion.fast` | 150ms |
| `motion.base` | 200ms |
| `motion.easing` | `cubic-bezier(0.2, 0, 0.38, 1)` |

Motion confirms that something changed. It never carries meaning on its own and it never makes the user wait.

- Interactive feedback — hover, press, toggle, focus, a border changing colour: `motion.fast`.
- Content transitions — panel expansion, dialog enter and exit, drawers: `motion.base`.
- Animate `opacity` and `transform` only. In a preview-heavy interface where the GPU is already busy, animating `height` or `width` will drop frames.
- Nothing exceeds `motion.base`. Nothing bounces, overshoots or lingers.
- Generation progress is not an animation. It is a number and a stage name.
- No entrance animation on load.
- `prefers-reduced-motion: reduce` collapses every duration to zero. Nothing is lost, because no state is ever conveyed by motion alone.

## Iconography

**Icons are inlined as stroked SVG, not an icon font.**

Do not use an icon font from a CDN. In practice the probability of it failing to load is not small (network policy, sandboxes, offline), and the failure mode is bad: icon-only buttons become empty boxes, or degrade into substitute glyphs like a solid triangle that is stylistically wrong. Given how often icons appear, betting on an external dependency is not worth it.

Specification:

- `viewBox="0 0 16 16"` (16px) or `0 0 20 20` (20px)
- `fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round`
- Colour is always inherited from a semantic text token on the parent. An icon with its own hardcoded colour is an icon that will be wrong in the next state.

Icon-only controls use four hit-area steps: 20, 24, 32 and 40 pixels square, with glyphs at 16, 18, 20 and 24. 32 is the default. **The 20px step is below the accessible minimum** and is only permitted when the same action is also reachable through a target that clears 24px. Note that the step names collide with the control heights but the numbers differ: a `mini` control is 24px tall, a `mini` icon button is 20px square.

Three kinds of icon, three obligations. A **decorative** icon beside its own text label takes `aria-hidden` so it is not announced twice. An **interactive** icon-only button takes an `aria-label` describing the action rather than the picture. An **indicative** icon carrying state needs screen-reader text, because colour and shape alone convey nothing.

**Directional** icons — arrows, chevrons, back and forward, indent, undo — need a class that RTL can flip. Clocks, checkmarks, play buttons, logos and charts with a time axis must never be flipped. Up and down arrows need no flipping, so prefer them where there is a choice.

## Direction

The platform renders your generator in a document whose `dir` follows the user's locale, and several supported locales read right to left. Mirroring is not optional and there is no second stylesheet — the layout has to flip itself.

Written the modern way this is nearly free. Every one-sided value uses a logical property: `margin-inline-start`, `padding-inline-end`, `inset-inline-start`, `text-align: start`. Symmetric shorthands need no attention, and flex and grid mirror themselves — adding `flex-row-reverse` for RTL flips a layout that had already flipped.

**The board, the rulers and the artifact are never mirrored.** Lock that layer to `direction: ltr` and the logical properties inside it always resolve to the left, so the ruler origin, the tick direction and the orientation of the exported file stay independent of the interface around them. The lock stops at the artifact: **the overlays floating on the canvas are interface, not artifact**, and they mirror with everything else — in RTL the zoom cluster and the regenerate control belong on the mirrored side. Locking the whole canvas region instead would strand a right-to-left reader's controls on the wrong edge.

Set `dir="rtl"` and walk the whole flow before submitting. Traps measured in this system:

- The slider's webkit fill uses `linear-gradient(to right …)` and needs `to left` in RTL
- The drag-to-change direction has to invert
- When positioning the dropdown popover with JS, align to the trigger's **end** edge in RTL rather than copying `left`
- Do not centre a floating hint with `inset-inline-start: 50%` plus `translateX(-50%)`; use `inset-inline: 0; margin-inline: auto`
- The progress bar's `transform-origin` has to switch sides
- The switch knob starts from `inset-inline-start` but travels with `translateX(16px)`; `transform` is physical, so RTL needs `-16px`. Any transform that means "move forward" decides its own direction — this list is the traps already recorded, not an exhaustive set

## Accessibility

The floor, not the ceiling:

- **Every interactive target is at least 24×24px** (WCAG 2.2 SC 2.5.8). Check the width too, because icon-only buttons are square. Where a visual is deliberately shorter — a slider rail, a checkbox glyph — pad the hit area rather than shrink the target.
- **Verify contrast per pairing, never assume it.** Recompute the key pairings every time the ramp changes, including the worst case of a small size on a light fill.
- **Keyboard reaches everything a mouse reaches**, in visible order. Dialogs trap focus while open and return it to the trigger on close. A hand-built listbox owes the contract a native `<select>` gives away; see Components.
- **`[hidden]` needs restoring.** Any component that sets `display: flex` or `grid` overrides the browser default `[hidden] { display: none }`. Ship `[hidden] { display: none !important }` in the base stylesheet — that is exactly how an off-screen panel leaks into view.
- **Meaning never rides on colour, shape or motion alone.** A red border needs a message; a spinner needs text; a selected state needs a checkmark.

### Known and deliberately accepted deviations

A specification should record its own debts rather than pretend it has none:

| Item | Measured | Threshold | Reasoning |
|---|---|---|---|
| `stroke-default` as an overlay's only boundary | 1.24:1 | 3:1 (1.4.11) | Overlays also carry a shadow, so the boundary is not the border alone |
| `stroke-input` as an input's only boundary | 1.47:1 | 3:1 (1.4.11) | The primary source of the overall feel; compliance would require going to roughly `#949ba5`, which visibly heavies every edge |
| Ruler numerals | 2.47:1 | 4.5:1 | Instrument markings are not content; the artboard dimensions appear as explicit values in the results panel |

If a project requires AA across the board, the first two are solved by shipping a `stroke-input` override — one token.

## Platform Constraints

These are not stylistic choices — they are conditions of running inside atomm.

**Export must go through the platform button.** Place `<div data-atomm-export-button></div>` where you want it and register an `atomm.lifecycle.on('export', …)` hook. The SDK renders the button and only its `--atomm-export-*` variables are themeable. A submission with no export hook is automatically rejected at review. During development you may render a fallback button in the same slot, but the SDK must always take precedence when present.

**Treat the exported file as machine-facing.** 1:1 real dimensions, `mm` units, `width="200mm"` with a viewBox in mm coordinates. Cut-line colours may be mapped to machine processes by the device, so changing them requires separate confirmation and does not follow the interface ramp. Reference images used for preview never enter the drawing.

**The top bar belongs to the platform** — brand, account, credits. Do not rebuild it and do not imitate it. Your own controls (project, save, history) go in a light bar above your own content.

**You render inside the region the platform allocates.** You cannot cover the platform chrome, you do not know the viewport, and the stacking context is entirely yours — one overlay layer plus one for toasts is enough, and `z-index` caps at 3.

## Do's and Don'ts

Gradients, glass, pills and hero moments are already ruled out by the reference in Overview. What follows is only what a reference cannot carry — and every line has been hit for real in this system.

### Colour

- **Do** make the neutral ramp's coolness consistent and committed. Half-hearted residual chroma reads as dirty.
- **Do** make each text step roughly half the one above. Four steps bunched at one end are two steps.
- **Do** recompute the key pairings whenever the ramp changes, especially the worst case of a small size on a light fill.
- **Don't** make the canvas darker than the panels. The artifact is the only thing on screen that should be loud.
- **Don't** treat "it looks grey" as verification. `background-input` measures 1.10:1 on white — drawing a track with it is drawing nothing.

### Hierarchy

- **Do** build hierarchy by removing weight: labels recede into a lighter secondary colour so values can step forward.
- **Do** separate three layers inside one size with weight and colour rather than adding another size.
- **Don't** put structural labels and explanatory copy in the same register. One is the skeleton, the other is a footnote.
- **Don't** solve "unclear hierarchy" by adding weight. Nine things shouting is no emphasis at all.

### Shape and space

- **Do** let uniformity land on the **shape** — fill, border, radius and height must be identical.
- **Don't** let uniformity land on the **width**. One size for all leaves a single digit lying in a three-digit box; the layout already aligns the end edge.
- **Don't** wrap cards inside a panel that is already a closed region. Use a line.
- **Don't** reserve a slot each for two elements that never appear at the same time (the unit and the drag handle).

### Controls

- **Do** spend the red emphasize tier on exactly one action per screen, or none.
- **Do** treat the state set as closed. A control that seems to need a ninth state needs rethinking instead.
- **Do** write out the keyboard and screen-reader contract a native control gives away before replacing it, then repay it item by item.
- **Don't** let a decorative layer take pointer events away from the control underneath it.
- **Don't** fill a destructive button with red. Secondary shell, `error` text, plus a confirmation.
- **Don't** put two drag entry points on the same row.

### Engineering

- **Do** give panels that must fill their space `flex: 1`, not just `min-height: 0`.
- **Do** inline icons as stroked SVG. An icon font is an external dependency whose failure mode is an empty box.
- **Do** run a dangling-variable check after every token change. Deleting a neutral step while a semantic token still references it makes some state fail silently.
- **Don't** rely on a classic scrollbar taking no space. Use a thin one, or the right side of the panel ends up twice as wide as the left.
- **Don't** leave a popover inside a scroll container. Mount it on `body` with `position: fixed` or it will be clipped.
