diff --git a/design_system/README.md b/design_system/README.md
new file mode 100644
index 0000000..0223a9e
--- /dev/null
+++ b/design_system/README.md
@@ -0,0 +1,125 @@
+# Garage design system
+
+The look of every Garage app — the hub, the payment portal, Arcs & Angles — as
+tokens, CSS and preview cards, for designing new Garage screens.
+
+It's generated from the Flutter package, not drawn separately:
+`tokens.css` and `tokens.json` come out of
+`garage_ui/tool/export_design_tokens_test.dart`, and `components.css` only
+uses those tokens. Don't edit the token files by hand — change garage_ui and
+rerun:
+
+```sh
+cd garage_ui && flutter test tool/export_design_tokens_test.dart
+```
+
+```
+design_system/
+ tokens.css, tokens.json colours, density, type, shape — generated
+ components.css the components, built on the tokens
+ foundations/ colour, type, spacing, shape, density
+ components/ buttons, fields, selects, lists, dialogs, …
+ patterns/ whole screens from the hub and Arcs & Angles
+```
+
+
+## The one rule: let the theme speak
+
+The colour scheme, the density and the component's variant decide every
+colour, font size, weight, icon size, padding, radius and gap. Designing a
+Garage screen is choosing **which component** and **which variant**. Then
+leave it alone.
+
+- No custom colours, font sizes or weights on a component or its contents.
+- No icon sizes or colours — an icon takes them from where it sits.
+- No hand-picked padding, gaps or radii. Use the `--gap-*` and `--radius-*`
+ tokens, and let components that already carry spacing keep it.
+- No home-made badges, pills, warning boxes or popovers built from a box with
+ a fill and a border. If a component doesnt exist, that's a gap in the system,
+ not an invitation.
+- No shadows. Everything is flat — surfaces are separated by a step in
+ lightness and a hairline border.
+
+If something looks wrong, the answer is a different variant or a different
+component.
+
+
+## Setting up a screen
+
+```html
+
+
+```
+
+- **Scheme:** `carbon` (default — true black, no accent hue; what the Garage
+ apps run), `dark`, `light`.
+- **Density:** `product` (default — web and product apps, 12px text, 33px
+ controls), `compact` (dense desktop tools like Arcs & Angles, 10px text, 23px
+ controls), `normal` (compact but roomier). Pick one per app. Don't mix.
+
+Type is Geist, and Geist Mono for numbers and readouts. Body text is weight
+300, control labels 400, primary buttons 600.
+
+
+## Layout
+
+Every Garage screen is the same shape: a `chrome` ground, `panel`s standing on
+it, and `panel-gap` gutters between them.
+
+```html
+
+
+ …workspace…
+
+```
+
+Headers and footers are `chrome-bar`s. Only one panel is highlighted
+(`panel active`) at a time.
+
+A hub page is a centred column inside the workspace panel: a `page-heading`,
+then either a stack of `properties-section`s or `sub-heading` + `settings-list`
+forms. See `patterns/`.
+
+
+## Choosing components
+
+**Buttons**
+
+| Variant | For |
+|---|---|
+| `primary` | the one action on the view, or the thing that's on / chosen |
+| `secondary` | a neutral filled control; the selected nav item; a button welded to a field |
+| `outline` | a normal action that isn't the main one — Cancel, Retry |
+| `ghost` | chrome, toolbars, nav items, row actions |
+| `ghost-destructive` | remove / revoke / delete as a row action |
+| `destructive` | the confirm button of a destructive dialog |
+| `link`, `text` | inline in copy |
+
+Icon-only buttons are `btn icon-only`. A nav item is ghost with a leading icon,
+left-aligned, secondary when selected — never primary. Toolbar toggles are
+primary when on, outline when off.
+
+**Fields**
+
+- Free text: the default outline `field`.
+- Values inside a properties section: `field secondary`. A read-only value is a
+ read-only secondary field — **never bare text** in a value slot.
+- Selects: secondary in a properties section, `ghost` in a settings list,
+ outline elsewhere.
+- Numbers in an editor: secondary, centred, unit as a muted suffix.
+
+**Lists**
+
+- `properties-section` — a boxed card of label/value rows. Inspectors,
+ account pages, one card per record in a list. Controls inside are secondary.
+- `settings-list` — unboxed rows ruled between entries, for forms. Controls
+ inside are ghost; text fields are outline.
+
+**Subtitles** are short static descriptions of what something is. Never live
+data — no counts, dates, names, emails or statuses. Facts go in rows.
+
+**Dialogs** are `dialog` with the actions in their band: outline Cancel, then
+a primary or destructive confirm. Max width is fixed; don't widen it.
+
+**Loading** is a spinner. No skeletons. **Empty** is one muted line.
+**Error** is the message, muted, and an outline Retry.
diff --git a/design_system/components.css b/design_system/components.css
new file mode 100644
index 0000000..6184235
--- /dev/null
+++ b/design_system/components.css
@@ -0,0 +1,538 @@
+/* garage_ui components, in plain css. Built on tokens.css - every value here
+ is a token or a formula the flutter package uses too. If something needs a
+ new number, it goes in garage_ui first and comes out through the export.
+ The odd literal is only here because the dart source has the same literal,
+ and its commented where it happens.
+
+ Set data-scheme / data-density on (or any container) and everything
+ below follows.
+
+ States: every :hover / :active has an .is-hovered / .is-active twin, so a
+ card can show a state sat still without anyone pointing at it. */
+
+@import url("tokens.css");
+
+*, *::before, *::after { box-sizing: border-box; }
+
+body, .gu {
+ margin: 0;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ font-size: var(--font-size);
+ line-height: var(--line-height);
+ font-weight: var(--weight-body);
+ -webkit-font-smoothing: antialiased;
+}
+
+/* a nested scheme / density container has to re-resolve the things body set,
+ or it just inherits the outer values and only the components change */
+[data-scheme] { background: var(--background); color: var(--foreground); }
+[data-density] { font-size: var(--font-size); line-height: var(--line-height); }
+
+/* ---- type ------------------------------------------------------------ */
+
+.text-xxs { font-size: var(--text-xxs); }
+.text-xs { font-size: var(--text-xs); }
+.text-sm { font-size: var(--text-sm); }
+.text-lg { font-size: var(--text-lg); line-height: 1.3; }
+.medium { font-weight: var(--weight-medium); }
+.semibold { font-weight: var(--weight-semibold); }
+.bold { font-weight: 700; }
+.muted { color: var(--muted-foreground); }
+.mono { font-family: var(--font-mono); font-weight: 400; }
+
+/* PageHeading - title a step up, muted line under it, gapXs in from the side
+ (the margin a properties section carries). trailing sits gapMd off the
+ heading, centred on the whole block not the title line */
+.page-heading { padding: 0 var(--gap-xs); display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--gap-md); align-items: center; }
+.page-heading > :not(.trailing) { grid-column: 1; }
+.page-heading .title { font-size: var(--text-lg); font-weight: var(--weight-control); line-height: 1.3; }
+.page-heading .subtitle { margin-top: var(--gap-xs); color: var(--muted-foreground); }
+/* the hub puts a gapXs between two asides when theres more then one */
+.page-heading > .trailing { grid-column: 2; grid-row: 1 / span 2; display: flex; align-items: center; gap: var(--gap-xs); }
+
+/* subHeading - the same shape one size down, more room over it than under */
+.sub-heading { padding: var(--gap-xxl) var(--gap-xs) var(--gap-lg); display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: var(--gap-md); align-items: center; }
+.sub-heading > :not(.action) { grid-column: 1; }
+.sub-heading .title { font-size: var(--text-sm); font-weight: var(--weight-semibold); }
+.sub-heading .subtitle { margin-top: var(--gap-xxs); font-size: var(--text-xxs); color: var(--muted-foreground); }
+.sub-heading > .action { grid-column: 2; grid-row: 1 / span 2; display: flex; align-items: center; gap: var(--gap-xs); }
+
+.caption { font-size: var(--text-xxs); font-weight: var(--weight-semibold); color: var(--muted-foreground); }
+
+/* ---- icons ----------------------------------------------------------- */
+/* lucide, as inline svg. size comes from where it sits, never the call site */
+
+.icon { width: var(--icon-sm); height: var(--icon-sm); flex: none; stroke: currentColor; fill: none;
+ stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.icon-md { width: var(--icon-md); height: var(--icon-md); }
+.icon-lg { width: var(--icon-lg); height: var(--icon-lg); }
+
+/* ---- buttons --------------------------------------------------------- */
+/* every variant shares the same control padding - only the density moves it.
+ link and text included, they just dont paint a box */
+
+.btn {
+ display: inline-flex; align-items: center; justify-content: center;
+ gap: var(--control-gap);
+ height: var(--control-height);
+ padding: 0 var(--control-padding-x);
+ border: var(--control-border-width) solid transparent;
+ border-radius: var(--radius-md);
+ background: transparent;
+ color: var(--foreground);
+ font: inherit; font-size: var(--font-size); font-weight: var(--weight-control);
+ white-space: nowrap; cursor: pointer; user-select: none;
+ outline: none;
+}
+.btn:focus-visible { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 50%, transparent); }
+.btn:disabled, .btn.disabled { cursor: default; color: var(--muted-foreground); }
+.btn.icon-only { width: var(--control-height); padding: 0; }
+.btn.stretch { display: flex; width: 100%; }
+.btn.align-left { justify-content: flex-start; }
+
+.btn.primary { background: var(--primary); border-color: var(--control-border); color: var(--primary-foreground); font-weight: var(--weight-semibold); }
+.btn.primary:hover:not(:disabled), .btn.primary.is-hovered { background: var(--primary-hovered); }
+
+.btn.secondary { background: var(--secondary); border-color: var(--control-border); color: var(--secondary-foreground); }
+.btn.secondary:hover:not(:disabled), .btn.secondary.is-hovered { background: var(--secondary-hovered); }
+
+/* outline shares the text field's fill + stroke on purpose - one family */
+.btn.outline { background: var(--control-fill); border-color: var(--control-border); }
+.btn.outline .icon { color: var(--muted-foreground); }
+.btn.outline:hover:not(:disabled), .btn.outline.is-hovered { background: var(--control-fill-hovered); }
+
+/* ghost tints what its standing on instead of painting a colour */
+.btn.ghost:hover:not(:disabled), .btn.ghost.is-hovered { background: color-mix(in srgb, var(--foreground) 7%, transparent); }
+.btn.ghost:active:not(:disabled), .btn.ghost.is-active { background: color-mix(in srgb, var(--foreground) 12%, transparent); }
+
+.btn.ghost-destructive { color: var(--destructive); }
+.btn.ghost-destructive:hover:not(:disabled), .btn.ghost-destructive.is-hovered { background: color-mix(in srgb, var(--destructive) 12%, transparent); }
+.btn.ghost-destructive:active:not(:disabled), .btn.ghost-destructive.is-active { background: color-mix(in srgb, var(--destructive) 20%, transparent); }
+
+/* white is a literal in button.dart - "straight up white regardless of light or dark mode" */
+.btn.destructive { background: color-mix(in srgb, var(--destructive) 50%, transparent); color: #fff; border-color: transparent; }
+.btn.destructive:hover:not(:disabled), .btn.destructive.is-hovered { background: color-mix(in srgb, var(--destructive) 80%, transparent); }
+
+.btn.link:hover:not(:disabled), .btn.link.is-hovered { text-decoration: underline; }
+
+.btn.text { color: var(--muted-foreground); }
+.btn.text:hover:not(:disabled), .btn.text.is-hovered { color: var(--primary); }
+
+/* disabled, per variant, straight off button.dart. primary goes to a muted
+ foreground slab so its label still reads; secondary keeps its fill and
+ only the label dims; destructive swaps to the primary foreground */
+.btn.primary:disabled, .btn.primary.disabled { background: var(--muted-foreground); color: var(--primary-foreground); }
+.btn.secondary:disabled, .btn.secondary.disabled { background: var(--secondary); color: var(--muted-foreground); }
+.btn.ghost-destructive:disabled, .btn.ghost-destructive.disabled { background: transparent; color: var(--muted-foreground); }
+.btn.destructive:disabled, .btn.destructive.disabled { background: var(--primary-foreground); color: var(--muted-foreground); }
+/* text has no disabled look in the source, it just stays muted */
+.btn.text:disabled, .btn.text.disabled { color: var(--muted-foreground); }
+
+/* button group - joined, only the outer corners round */
+.btn-group { display: inline-flex; }
+.btn-group > * { border-radius: 0; }
+.btn-group > *:first-child { border-top-left-radius: var(--radius-md); border-bottom-left-radius: var(--radius-md); }
+.btn-group > *:last-child { border-top-right-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); }
+.btn-group > * + * { margin-left: calc(var(--control-border-width) * -1); }
+.btn-group.fill { display: flex; }
+.btn-group.fill > * { flex: 1; }
+/* span: the group takes the width its given and ONE member (.grow) takes the
+ slack - a field with its IconButton welded on, the button keeping its square */
+.btn-group.span { display: flex; width: 100%; }
+.btn-group > .grow { flex: 1; min-width: 0; }
+.btn-group.vertical { flex-direction: column; }
+.btn-group.vertical > * { border-radius: 0; }
+.btn-group.vertical > *:first-child { border-radius: var(--radius-md) var(--radius-md) 0 0; }
+.btn-group.vertical > *:last-child { border-radius: 0 0 var(--radius-md) var(--radius-md); }
+.btn-group.vertical > *:only-child { border-radius: var(--radius-md); }
+.btn-group.vertical > * + * { margin-left: 0; margin-top: calc(var(--control-border-width) * -1); }
+/* a group inside a group - a corner only rounds where BOTH groups say so
+ (ButtonGroupCorners.merge, its a product of the two) */
+.btn-group:not(.vertical) > .btn-group:not(:first-child) > * { border-top-left-radius: 0; border-bottom-left-radius: 0; }
+.btn-group:not(.vertical) > .btn-group:not(:last-child) > * { border-top-right-radius: 0; border-bottom-right-radius: 0; }
+.btn-group.vertical > .btn-group:not(:first-child) > * { border-top-left-radius: 0; border-top-right-radius: 0; }
+.btn-group.vertical > .btn-group:not(:last-child) > * { border-bottom-left-radius: 0; border-bottom-right-radius: 0; }
+
+/* ---- text fields ----------------------------------------------------- */
+
+.field {
+ display: inline-flex; align-items: center; gap: var(--control-gap);
+ height: var(--control-height);
+ padding: 0 calc(var(--control-padding-x) - var(--control-border-width));
+ background: var(--control-fill);
+ border: var(--control-border-width) solid var(--control-border);
+ border-radius: var(--radius-md);
+ color: var(--foreground);
+ font-weight: var(--weight-control);
+ min-width: 0;
+}
+/* only an outline field that can be typed in reacts - secondary is a flat
+ secondary fill in every state, and a read only one is just looked at */
+.field:not(.secondary):not(.readonly):hover, .field:not(.secondary):not(.readonly).is-hovered { background: var(--control-fill-hovered); }
+.field:not(.secondary):not(.readonly):focus-within, .field:not(.secondary):not(.readonly).is-focused { background: var(--control-fill-focused); }
+.field input, .field textarea {
+ all: unset; flex: 1; min-width: 0; width: 100%;
+ font-family: var(--font-sans); font-size: var(--font-size); font-weight: var(--weight-control);
+ color: inherit;
+}
+.field input::placeholder { color: var(--muted-foreground); }
+.field .icon, .field .unit { color: var(--muted-foreground); }
+.field.secondary { background: var(--secondary); color: var(--secondary-foreground); }
+.field.secondary .icon { color: var(--secondary-foreground); }
+/* one rule for "you cant change this" - the value AND the icons beside it */
+.field.readonly, .field.readonly .icon { color: var(--muted-foreground); }
+.field.center input { text-align: center; }
+/* ValueField(monospace:) - for ids and skus, read a character at a time */
+.field.mono input { font-family: var(--font-mono); }
+/* a rejected value: the outline goes full destructive, never the fill */
+.field.error { border-color: var(--destructive); }
+.field.stretch { display: flex; width: 100%; }
+
+/* ---- select ---------------------------------------------------------- */
+/* the value sits in the middle of the TRIGGER - a chevron's worth of nothing
+ on the left balances the chevron on the right */
+
+.select { display: inline-flex; align-items: center; justify-content: space-between; cursor: pointer; gap: var(--control-gap);
+ height: var(--control-height); padding: 0 calc(var(--control-padding-x) - var(--control-border-width));
+ background: var(--control-fill); border: var(--control-border-width) solid var(--control-border);
+ border-radius: var(--radius-md); color: var(--foreground); font-weight: var(--weight-control); white-space: nowrap; }
+.select::before { content: ""; flex: none; width: var(--icon-sm); }
+.select > span { flex: 1; min-width: 0; text-align: center; }
+.select .placeholder { color: var(--muted-foreground); }
+.select .chevron { color: color-mix(in srgb, currentColor 50%, transparent); }
+.select:hover, .select.is-hovered { background: var(--control-fill-hovered); }
+.select.secondary { background: var(--secondary); color: var(--secondary-foreground); }
+.select.secondary:hover, .select.secondary.is-hovered { background: var(--secondary-hovered); }
+.select.ghost { background: transparent; border-color: transparent; }
+.select.ghost:hover, .select.ghost.is-hovered { background: var(--popover-item-hovered); }
+.select.disabled { color: var(--muted-foreground); cursor: default; }
+.select.disabled:hover { background: var(--control-fill); }
+.select.secondary.disabled:hover { background: var(--secondary); }
+.select.ghost.disabled:hover { background: transparent; }
+.select.error { border-color: var(--destructive); }
+
+/* the select popup - its own surface, not a menu. inset is half the container
+ gap, the corner is concentric with the rows (less 2, a literal in
+ select.dart - the full sum read too soft) */
+.select-popup {
+ display: inline-flex; flex-direction: column; gap: var(--gap-xxs);
+ padding: calc(var(--container-gap) * 0.5);
+ background: var(--popover);
+ border: var(--control-border-width) solid var(--popover-border);
+ border-radius: calc(var(--radius-md) + var(--container-gap) * 0.5 + var(--control-border-width) - 2px);
+ color: var(--foreground);
+}
+.select-popup .option {
+ display: flex; align-items: center; justify-content: space-between; gap: var(--control-gap);
+ height: var(--popup-row-height);
+ padding: 0 calc(var(--control-padding-x) - var(--control-border-width));
+ border: var(--control-border-width) solid transparent;
+ border-radius: var(--radius-md);
+ font-weight: var(--weight-control); white-space: nowrap; cursor: default;
+}
+.select-popup .option:hover, .select-popup .option.is-hovered { background: var(--popover-item-hovered); }
+.select-popup .option.disabled { color: var(--muted-foreground); }
+/* the selected row IS a secondary button, stroke and all, with the check */
+.select-popup .option.selected { background: var(--secondary); border-color: var(--control-border); color: var(--secondary-foreground); }
+.select-popup .option.selected:hover, .select-popup .option.selected.is-hovered { background: var(--secondary-hovered); }
+/* once anything is selected every row reserves the check column, mirrored on
+ the left, so the labels stay centred on the row */
+.select-popup:has(.option.selected) .option::before,
+.select-popup:has(.option.selected) .option:not(.selected)::after { content: ""; flex: none; width: var(--icon-sm); }
+
+/* ---- popovers, menus, tooltips --------------------------------------- */
+
+/* MenuPopup - the 2px inset and 1px border are literals in menu.dart */
+.popover {
+ display: inline-flex; flex-direction: column;
+ background: var(--popover);
+ border: 1px solid var(--popover-border);
+ border-radius: var(--radius-md);
+ padding: 2px;
+ font-weight: var(--weight-control);
+}
+/* a row: an 18px icon slot and a 4px gap after it, the icon nudged toward the
+ edge by (4 - padding) / 2. 18, 4 and the 4px row radius are all literals in
+ menu.dart */
+.menu-item {
+ display: flex; align-items: center; gap: 4px;
+ height: var(--menu-row-height);
+ padding: 0 var(--control-padding-x);
+ border-radius: 4px;
+ color: var(--foreground); font-weight: var(--weight-control); white-space: nowrap; cursor: default;
+}
+.menu-item > .icon:first-child { margin: 0 calc((18px - var(--icon-sm)) / 2); position: relative; left: calc((4px - var(--control-padding-x)) / 2); }
+.menu-item .icon { color: var(--muted-foreground); }
+.menu-item .shortcut { margin-left: auto; padding-left: var(--container-gap); color: var(--muted-foreground); font-size: var(--font-size); }
+.menu-item:hover, .menu-item.is-hovered { background: var(--row-hovered); }
+.menu-item.disabled { color: color-mix(in srgb, var(--foreground) 50%, transparent); }
+.menu-item.disabled .icon { color: color-mix(in srgb, var(--muted-foreground) 50%, transparent); }
+.menu-item.disabled:hover { background: transparent; }
+/* MenuLabel - same padding as a row, so the same height, just semibold */
+.menu-label { display: flex; align-items: center; height: var(--menu-row-height); padding: 0 var(--control-padding-x); font-weight: var(--weight-semibold); white-space: nowrap; }
+/* 4px either side is a literal in menu.dart */
+.menu-divider { height: 1px; background: var(--divider); margin: 4px 0; }
+
+/* TooltipContainer - three quarters of the container padding across, three
+ quarters of the container gap down */
+.tooltip {
+ display: inline-block;
+ background: var(--tooltip-background);
+ border: 1px solid var(--tooltip-border);
+ border-radius: var(--radius-sm);
+ padding: calc(var(--container-gap) * 0.75) calc(var(--container-padding) * 0.75);
+ color: var(--foreground);
+ font-size: var(--font-size); font-weight: var(--weight-control);
+}
+
+/* ---- selection controls ---------------------------------------------- */
+
+/* 16px box, 1px border - literals in selection_controls.dart */
+.checkbox {
+ display: inline-flex; align-items: center; justify-content: center; flex: none;
+ width: 16px; height: 16px;
+ background: var(--secondary);
+ border: 1px solid var(--control-border);
+ border-radius: var(--radius-sm);
+}
+.checkbox.checked { background: var(--primary); border-color: var(--primary); }
+/* the tick is drawn, not an icon: a 9 x 6.5 check on a 1px round stroke, same
+ geometry as AnimatedCheckPainter. the mask box is half a pixel bigger each
+ side so the round caps dont get clipped */
+.checkbox.checked::after {
+ content: ""; width: 10px; height: 7.5px;
+ background: var(--primary-foreground);
+ -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-0.5 -0.5 10 7.5'%3E%3Cpolyline points='0,3.25 3.15,6.5 9,0' fill='none' stroke='black' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-0.5 -0.5 10 7.5'%3E%3Cpolyline points='0,3.25 3.15,6.5 9,0' fill='none' stroke='black' stroke-width='1' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") center / contain no-repeat;
+}
+.checkbox.indeterminate::after { content: ""; width: 8px; height: 8px; background: var(--primary); border-radius: var(--radius-xs); }
+.checkbox.disabled { border-color: var(--muted); }
+
+/* (32 + 4) x (16 + 4) is literal in the source; the inset is a quarter of the
+ container gap and the thumb is a square filling whats left */
+.switch {
+ display: inline-flex; align-items: stretch; flex: none;
+ width: 36px; height: 20px; padding: calc(var(--container-gap) * 0.25);
+ background: var(--switch-track-inactive);
+ border-radius: var(--radius-xl);
+}
+.switch::after { content: ""; height: 100%; aspect-ratio: 1; border-radius: var(--radius-lg); background: var(--foreground); }
+.switch.on { background: var(--primary); justify-content: flex-end; }
+.switch.on::after { background: var(--background); }
+.switch.disabled { background: var(--muted); }
+.switch.disabled::after { background: var(--muted-foreground); }
+
+/* ---- surfaces & layout ----------------------------------------------- */
+
+/* the whole look: chrome ground, panels standing on it, panel-gap gutters */
+.shell { background: var(--chrome); padding: var(--panel-gap); display: flex; gap: var(--panel-gap); }
+.panel {
+ background: var(--background);
+ border: var(--panel-border-width) solid var(--panel-border);
+ border-radius: var(--panel-radius);
+ overflow: hidden;
+}
+.panel.active { border-color: var(--panel-border-highlighted); }
+.panel.sunken { background: var(--surface-sunken); }
+
+.chrome-bar {
+ display: flex; align-items: center; gap: var(--gap-sm);
+ height: var(--chrome-bar-height);
+ background: var(--chrome);
+ padding: 0 var(--gap-lg);
+}
+.chrome-bar .vr { width: 1px; height: 12px; background: var(--divider); }
+
+.panel-header { display: flex; align-items: center; gap: 2px; padding: 10px 10px 8px; }
+.panel-header .icon { width: 12px; height: 12px; margin: 0 3px; color: var(--foreground); }
+.panel-header .title { font-weight: var(--weight-medium); flex: 1; }
+
+/* a nav rail's category, as the hub draws it - gapMd round the run, a
+ semibold caption over it, gapXxs between the buttons only */
+.nav-group { padding: var(--gap-md); display: flex; flex-direction: column; gap: var(--gap-xxs); }
+.nav-group > .caption { padding: 0 var(--gap-sm) calc(var(--gap-xs) - var(--gap-xxs)); }
+
+/* explorer tree rows (A&A). an 18px slot for the chevron and one for the
+ icon, 2px between, 12px glyphs - all literals in explorer_panel.dart. the
+ hover is inset 2px with a 3px corner, same file */
+.explorer-row {
+ --depth: 0;
+ position: relative; isolation: isolate;
+ display: flex; align-items: center; gap: 2px;
+ height: var(--explorer-row-height);
+ padding: 0 var(--explorer-row-end-padding) 0 calc(var(--explorer-row-base-padding) + var(--depth) * 18px);
+ color: var(--row-text); white-space: nowrap;
+}
+.explorer-row.depth-1 { --depth: 1; }
+.explorer-row.depth-2 { --depth: 2; }
+.explorer-row.depth-3 { --depth: 3; }
+.explorer-row > .slot { flex: none; width: 18px; display: flex; align-items: center; justify-content: center; }
+.explorer-row .icon { width: 12px; height: 12px; }
+.explorer-row .chevron { color: var(--muted-foreground); }
+.explorer-row > .name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
+.explorer-row:hover::before, .explorer-row.is-hovered::before {
+ content: ""; position: absolute; inset: 2px; z-index: -1;
+ background: var(--row-hovered); border-radius: 3px;
+}
+
+.card {
+ background: var(--card);
+ border: 1px solid var(--properties-section-border);
+ border-radius: var(--radius-xl);
+ padding: var(--container-padding);
+}
+.outlined {
+ background: var(--background);
+ border: 1px solid var(--control-border);
+ border-radius: var(--radius-xl);
+}
+.divider { height: 1px; background: var(--divider); border: 0; margin: 0; }
+
+/* ---- properties section --------------------------------------------- */
+
+.properties-section {
+ margin: 0 var(--gap-xs) var(--properties-section-gap);
+ background: var(--card);
+ border: 1px solid var(--properties-section-border);
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+}
+.properties-section > .header {
+ display: flex; align-items: center; gap: var(--gap-sm);
+ padding: var(--control-padding-y) var(--control-padding-x);
+ color: var(--row-text); font-weight: var(--weight-semibold);
+}
+.properties-section > .header .chevron { color: var(--muted-foreground); }
+/* the header's own subtitle - captionStyle, so the ambient weight not the title's */
+.properties-section > .header .subtitle { display: block; font-size: var(--text-xxs); color: var(--muted-foreground); font-weight: var(--weight-body); }
+.properties-section > .header .trailing { margin-left: auto; font-weight: var(--weight-control); }
+/* the last row gets more room under it than the step between rows */
+.properties-section > .rows { padding: 0 var(--control-padding-x) var(--gap-md); display: flex; flex-direction: column; gap: var(--gap-xs); }
+.properties-section > .actions {
+ display: flex; justify-content: flex-end; gap: var(--gap-sm);
+ border-top: 1px solid var(--properties-section-border);
+ background: color-mix(in srgb, var(--card) 55%, var(--muted));
+ padding: var(--gap-sm) var(--control-padding-x);
+}
+
+/* PropertyRow - the label up to the split, the control from it. the gap comes
+ out of the label's padding, so the control's edge lands right on the split.
+ it wraps, so an .error or .description can take a line of its own under it */
+.property-row { display: flex; flex-wrap: wrap; align-items: center; }
+.property-row > .label { flex: 0 0 calc(var(--property-split) * 100%); padding-right: var(--property-label-gap); min-width: 0; color: var(--row-text); text-align: right; }
+.property-row > .label .subtitle { display: block; font-size: var(--text-xxs); color: var(--muted-foreground); }
+.property-row > .label .tag { font-size: var(--text-xxs); color: var(--muted-foreground); margin-left: var(--gap-xs); }
+.property-row > .value { flex: 1 1 0; min-width: 0; min-height: var(--property-row-height); display: flex; align-items: center; gap: var(--control-gap); }
+.property-row > .value > .field, .property-row > .value > .select, .property-row > .value > .btn-group.span { flex: 1; }
+/* why the value was refused - under the row, centred, destructive */
+.property-row > .error { flex: 0 0 100%; margin-top: var(--gap-xs); text-align: center; font-size: var(--text-xxs); color: var(--destructive); }
+/* copy about the setting, in a box that matches the outline controls */
+.property-row > .description {
+ flex: 0 0 100%; margin-top: var(--gap-xs);
+ padding: var(--gap-xs) var(--control-padding-x);
+ background: var(--control-fill);
+ border: var(--control-border-width) solid var(--control-border);
+ border-radius: var(--radius-md);
+ text-align: center; font-size: var(--text-xxs); color: var(--muted-foreground);
+}
+
+/* ---- settings list --------------------------------------------------- */
+
+/* ruled BETWEEN rows, and the rule overshoots the text by the row inset so it
+ reads as a divider rather than the edge of a box */
+.settings-list { display: flex; flex-direction: column; }
+.settings-list > .settings-row + .settings-row { border-top: 1px solid var(--divider); }
+.settings-row { display: flex; flex-wrap: wrap; align-items: center; column-gap: var(--gap-md); padding: var(--gap-md) var(--gap-sm); }
+.settings-row > .label { flex: 1 1 0; min-width: 0; font-size: var(--text-xs); font-weight: var(--weight-control); color: var(--foreground); }
+.settings-row > .label .subtitle { display: block; margin-top: var(--gap-xxs); font-size: var(--text-xxs); font-weight: var(--weight-body); color: var(--muted-foreground); }
+.settings-row > .label .tag { font-size: var(--text-xxs); font-weight: var(--weight-control); color: var(--muted-foreground); margin-left: var(--gap-xs); }
+/* a loose Flexible: at most what the label leaves, field pinned right. a
+ select or a button keeps its own width, a text field fills the slot.
+ the minimum lives here rather than on the label so the label can stay a
+ plain block - its the height of a row WITH a subtitle, so a list mixing the
+ two doesnt come out ragged */
+.settings-row > .field-slot {
+ flex: 1 1 0; min-width: 0;
+ min-height: calc(var(--label-column-height) + var(--gap-xxs));
+ display: flex; align-items: center; justify-content: flex-end;
+}
+.settings-row > .field-slot > .field { flex: 1; }
+.settings-row > .error { flex: 0 0 100%; margin-top: var(--gap-sm); font-size: var(--text-xxs); color: var(--destructive); }
+.settings-row > .description { flex: 0 0 100%; margin-top: var(--gap-sm); font-size: var(--text-xxs); color: var(--muted-foreground); }
+.settings-row > .error + .description { margin-top: var(--gap-xxs); }
+
+/* ---- dialogs, toasts ------------------------------------------------- */
+
+/* AlertDialog's barrier - black at 0.8, no blur */
+.modal-backdrop { background: rgb(0 0 0 / 0.8); }
+/* pane overlays keep the lighter frosted scrim */
+.scrim { background: color-mix(in srgb, var(--background) 35%, transparent); backdrop-filter: blur(3px); }
+
+.dialog {
+ width: 100%; max-width: var(--dialog-max-width);
+ background: var(--card);
+ border: 1px solid var(--properties-section-border);
+ border-radius: var(--radius-xxl);
+ overflow: hidden;
+}
+.dialog > .body { padding: calc(var(--container-padding) * 1.2) calc(var(--container-padding) * 1.4) calc(var(--container-padding) * 0.6); display: flex; flex-direction: column; gap: var(--container-gap); }
+.dialog > .body .title { font-size: var(--text-xs); font-weight: var(--weight-semibold); }
+.dialog > .body .content { font-size: var(--text-xs); color: var(--muted-foreground); }
+.dialog > .actions {
+ display: flex; justify-content: flex-end; gap: var(--container-gap);
+ border-top: 1px solid var(--properties-section-border);
+ background: color-mix(in srgb, var(--card) 55%, var(--muted));
+ padding: var(--gap-sm) var(--control-padding-x);
+}
+
+/* the desktop toast. 300 wide, the 32px badge, the 18px glyph, the white on
+ it and the 3px bar are all literals in toast.dart */
+.toast {
+ --toast-accent: var(--primary);
+ display: flex; flex-direction: column;
+ width: 300px;
+ background: var(--popover);
+ border: 1px solid var(--popover-border);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+.toast.success { --toast-accent: var(--chart-2); }
+.toast.error { --toast-accent: var(--destructive); }
+.toast > .body { display: flex; align-items: flex-start; gap: var(--container-gap); padding: var(--container-gap); }
+.toast .badge {
+ flex: none; width: 32px; height: 32px;
+ display: flex; align-items: center; justify-content: center;
+ border: var(--control-border-width) solid var(--control-border);
+ border-radius: var(--radius-md);
+ color: #fff;
+}
+.toast .badge .icon { width: 18px; height: 18px; }
+.toast .text { flex: 1; min-width: 0; }
+/* title and subtitle are the same text, told apart by colour only */
+.toast .title { font-weight: var(--weight-control); color: var(--foreground); }
+.toast .subtitle { margin-top: calc(var(--container-gap) * 0.375); font-weight: var(--weight-control); color: var(--muted-foreground); }
+/* the countdown - the accent drains off the right, over the border colour at 0.4 */
+.toast > .drain { display: flex; height: 3px; background: color-mix(in srgb, var(--popover-border) 40%, transparent); }
+.toast > .drain > span { background: var(--toast-accent); }
+
+/* CircularProgressIndicator - no track, a 1.35pi arc (~243deg) on a 4px
+ round stroke, 18px, primary, once round every 900ms. all literal in
+ misc.dart. the two dots are the round caps */
+.spinner {
+ position: relative; flex: none;
+ width: 18px; height: 18px; border-radius: 50%;
+ background: conic-gradient(var(--primary) 0 243deg, transparent 0);
+ -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3.5px));
+ mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 3.5px));
+ animation: gu-spin 0.9s linear infinite;
+}
+.spinner::before, .spinner::after {
+ content: ""; position: absolute; left: 7px; top: 0;
+ width: 4px; height: 4px; border-radius: 50%;
+ background: var(--primary);
+ transform-origin: 2px 9px;
+}
+.spinner::after { transform: rotate(243deg); }
+@keyframes gu-spin { to { transform: rotate(360deg); } }
diff --git a/design_system/components/button-groups.html b/design_system/components/button-groups.html
new file mode 100644
index 0000000..04636a8
--- /dev/null
+++ b/design_system/components/button-groups.html
@@ -0,0 +1,94 @@
+
+
+
+
+
+Button groups
+
+
+
+
+
+
+
+
Button groups
+
Controls joined into one. Only the outer corners round.
+
+
+
+
+
Button welded to a field
+
+
+
+
+
+
+
+
Segmented choice
+
+
+
+
+
+
+
+
+
+
+
Vertical, with a group nested in it
+
+
+
+
+
+
+
+
+
+
+
Toolbar toggles - primary on, outline off
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Use it for
+
+
+
A field with its one action welded on: a spanning group, the field grows, then a secondary IconButton.
+
Groups nest. A corner only rounds where both groups agree it's an outer one.
+
A segmented choice: fill the group, chosen one primary, the rest secondary, labels centred.
+
Editor toolbar toggles: primary when on, outline when off.
+
+
+
+
Dont
+
+
+
Leave a segmented control shrunk to its labels. It should span its field.
+
Mix the primary-on toggle pattern and Toggle on the same surface.
+
Use a secondary off state unless its a master switch whose off state matters.