From b5e5ed5b75c952609e6e89159b57c9fb2e60bef4 Mon Sep 17 00:00:00 2001 From: ImBenji Date: Wed, 23 Sep 2026 23:19:02 +0100 Subject: [PATCH] Add a design system for Claude Design, generated from garage_ui Tokens come out of garage_ui/tool/export_design_tokens_test.dart, the components are CSS on top of them, and the cards cover foundations, components and whole hub and Arcs & Angles screens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7 --- design_system/README.md | 125 ++++ design_system/components.css | 538 ++++++++++++++++++ design_system/components/button-groups.html | 94 +++ design_system/components/buttons.html | 130 +++++ design_system/components/dialogs.html | 66 +++ design_system/components/feedback.html | 110 ++++ design_system/components/menus.html | 73 +++ .../components/properties-section.html | 94 +++ design_system/components/select.html | 84 +++ .../components/selection-controls.html | 59 ++ design_system/components/settings-list.html | 74 +++ design_system/components/text-fields.html | 98 ++++ design_system/foundations/colors.html | 140 +++++ design_system/foundations/density.html | 89 +++ design_system/foundations/shape.html | 77 +++ design_system/foundations/spacing.html | 80 +++ design_system/foundations/type.html | 99 ++++ design_system/patterns/editor-shell.html | 173 ++++++ design_system/patterns/hub-form.html | 135 +++++ design_system/patterns/hub-page.html | 151 +++++ design_system/patterns/settings-pane.html | 112 ++++ design_system/patterns/states.html | 68 +++ design_system/tokens.css | 259 +++++++++ design_system/tokens.json | 256 +++++++++ garage_ui/tool/export_design_tokens_test.dart | 197 +++++++ 25 files changed, 3381 insertions(+) create mode 100644 design_system/README.md create mode 100644 design_system/components.css create mode 100644 design_system/components/button-groups.html create mode 100644 design_system/components/buttons.html create mode 100644 design_system/components/dialogs.html create mode 100644 design_system/components/feedback.html create mode 100644 design_system/components/menus.html create mode 100644 design_system/components/properties-section.html create mode 100644 design_system/components/select.html create mode 100644 design_system/components/selection-controls.html create mode 100644 design_system/components/settings-list.html create mode 100644 design_system/components/text-fields.html create mode 100644 design_system/foundations/colors.html create mode 100644 design_system/foundations/density.html create mode 100644 design_system/foundations/shape.html create mode 100644 design_system/foundations/spacing.html create mode 100644 design_system/foundations/type.html create mode 100644 design_system/patterns/editor-shell.html create mode 100644 design_system/patterns/hub-form.html create mode 100644 design_system/patterns/hub-page.html create mode 100644 design_system/patterns/settings-pane.html create mode 100644 design_system/patterns/states.html create mode 100644 design_system/tokens.css create mode 100644 design_system/tokens.json create mode 100644 garage_ui/tool/export_design_tokens_test.dart 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.
  • +
+ +
+ + diff --git a/design_system/components/buttons.html b/design_system/components/buttons.html new file mode 100644 index 0000000..c20f7a8 --- /dev/null +++ b/design_system/components/buttons.html @@ -0,0 +1,130 @@ + + + + + +Buttons + + + + +
+ +
+
Buttons
+
Pick the variant, then stop. The theme does the rest.
+
+ +
+
Variants
+
+ + + + + + + + +
+
+ +
+
With a leading icon
+
+ + + + +
+
+ +
+
Icon only - IconButton, never a Button with just an icon in it
+
+ + + + + +
+
+ +
+
Disabled
+
+ + + + + + + +
+
+ +
+
Hovered
+
+ + + + + + + + +
+
+ + +
+
Variants mean something
+
+ + + + + + + + + +
The one action, or the thing thats on or chosen. One per view, ideally.
A neutral filled control. A resting toggle, a button welded to a field, the selected nav item.
A normal action that isnt the main one. Cancel, Retry, Previous and Next.
Chrome, toolbars, nav items, row actions. Anything that shouldnt compete.
The confirm button of a destructive dialog.
A destructive trigger that isnt a filled slab. Remove, Revoke, Delete in a row.
Inline, in running copy.
+ + +
+
Use it for
+
+
    +
  • Dialogs: Cancel is outline, confirm is primary or destructive.
  • +
  • Retry after an error, and an empty state's call to action: outline.
  • +
  • Panel header actions: a ghost IconButton, each in a Tooltip.
  • +
  • An action in a settings row: ghost. In a property row: secondary.
  • +
+ +
+
Dont
+
+
    +
  • Bold a button label, or colour its text or icon by hand.
  • +
  • Hand-colour a ghost button red. Thats what ghost destructive is for.
  • +
  • Set an alignment unless the button is stretched wider than its label.
  • +
  • Force a compact density on a button to make it fit.
  • +
+ +
+ + diff --git a/design_system/components/dialogs.html b/design_system/components/dialogs.html new file mode 100644 index 0000000..36ec903 --- /dev/null +++ b/design_system/components/dialogs.html @@ -0,0 +1,66 @@ + + + + + +Dialogs + + + + +
+ +
+
Dialogs
+
A small confirm, over a dimmed page.
+
+ + + + +
+
Use it for
+
+
    +
  • showDialog with an AlertDialog: a title, a line of content and its actions.
  • +
  • Cancel is outline. Confirm is primary, or destructive when it removes something.
  • +
  • Bigger jobs, like settings or export, go in a pane overlay instead.
  • +
+ +
+
Dont
+
+
    +
  • Constrain the dialog's width. It caps itself at the dialog max width.
  • +
  • Build the action row by hand. The dialog draws its own band.
  • +
+ +
+ + diff --git a/design_system/components/feedback.html b/design_system/components/feedback.html new file mode 100644 index 0000000..46e71e2 --- /dev/null +++ b/design_system/components/feedback.html @@ -0,0 +1,110 @@ + + + + + +Feedback + + + + +
+ +
+
Feedback
+
Toasts, and the three states a page can be in.
+
+ +
+
+
+
+
+
Link copied
+
Paste it anywhere a buyer can reach.
+
+ +
+
+
+
+
+
+
+
Saved
+
Your changes are live.
+
+ +
+
+
+ +
+ +
+
Loading, empty, error
+
The plainest thing that works.
+
+ +
+
+
Loading
+
+
+
+
+ +
+
Empty
+
+
No coupons yet.
+
+
+ +
+
Error
+
+
Couldnt load orders.
+ +
+
+
+ + +
+
Use it for
+
+
    +
  • showAppToast with a title, a subtitle and a severity: info, success or error. The severity colours the drain bar, nothing else.
  • +
  • Loading is a spinner. Empty is one muted line. Error is the message muted and an outline Retry.
  • +
+ +
+
Dont
+
+
    +
  • Use skeletons or shimmer.
  • +
  • Hand-build a toast out of a card and some padding.
  • +
  • Hand-build a warning box. A page that wants more is asking for a new garage_ui component.
  • +
+ +
+ + diff --git a/design_system/components/menus.html b/design_system/components/menus.html new file mode 100644 index 0000000..697003b --- /dev/null +++ b/design_system/components/menus.html @@ -0,0 +1,73 @@ + + + + + +Menus and tooltips + + + + +
+ +
+
Menus and tooltips
+
Floating surfaces. Flat, no shadow.
+
+ +
+
+
Menu
+ +
+ +
+
Context menu
+ +
+ +
+
Tooltip
+ + Copy client ID +
+
+ + +
+
Use it for
+
+
    +
  • MenuButton rows, with MenuLabel and MenuDivider to group them.
  • +
  • showContextMenu for right-click. Property rows get Reset, Copy and Paste, greyed when theres nothing to do.
  • +
  • A tooltip on every icon-only button, with plain text in it.
  • +
+ +
+
Dont
+
+
    +
  • Hand-build a popover out of a container with a fill, border and radius.
  • +
  • Colour tooltip text, or pad the tooltip yourself.
  • +
  • Add a shadow to a menu. Surfaces are separated by their border and fill.
  • +
+ +
+ + diff --git a/design_system/components/properties-section.html b/design_system/components/properties-section.html new file mode 100644 index 0000000..37538eb --- /dev/null +++ b/design_system/components/properties-section.html @@ -0,0 +1,94 @@ + + + + + +Properties section + + + + +
+ +
+
Properties section
+
A boxed card of label and value rows.
+
+ +
+
+ + Subscription + Active +
+ +
+
+
Plan
+
+
+ +
+
SeatsPeople who can sign in
+
+
Needs at least one seat
+
+ +
+
Billing
+
+
+ Monthly + +
+
+
Switching takes effect from the next invoice, not this one.
+
+ +
+
Invoices
+
+
+
+ +
+ + +
+
+ + +
+
The value slot is never bare text
+
A read-only value is a secondary field, read only and disabled.
+
+
    +
  • Beside real fields, bare text reads as a caption and the column goes ragged.
  • +
  • A button in the value slot sits left and keeps its own width. It doesnt stretch.
  • +
  • A refused value says why in the row's error, under it. Copy about the setting goes in the description box.
  • +
+ +
+
Use it for
+
+
    +
  • Inspectors, account and security pages, and lists of records, one section per record.
  • +
  • A status or count in the header's trailing slot. It stays visible when collapsed.
  • +
  • What the section can do, in the actions band under the rows.
  • +
+ +
+
Dont
+
+
    +
  • Use outline fields or ghost selects in here. Controls in a section are secondary.
  • +
  • Put live data in the subtitle. No counts, dates, names or statuses.
  • +
  • Add gaps between sections. Each one carries its own margin.
  • +
+ +
+ + diff --git a/design_system/components/select.html b/design_system/components/select.html new file mode 100644 index 0000000..652a658 --- /dev/null +++ b/design_system/components/select.html @@ -0,0 +1,84 @@ + + + + + +Select + + + + +
+ +
+
Select
+
One value from a list, in a popup.
+
+ +
+
+
Outline - the default
+
+ Engineering + +
+
+ +
+
Secondary - in a property row
+
+ Monthly + +
+
+ +
+
Ghost - in a settings row
+
+ All regions + +
+
+
+ +
+
Open
+
+ Engineering + +
+
+
Design
+
Engineering
+
Finance
+
Support
+
+
+ + +
+
Use it for
+
+
    +
  • Outline by default, beside other outline fields.
  • +
  • Secondary in a property row. The row forces it anyway.
  • +
  • Ghost in a settings row, or where it reports a value rather than offering a control.
  • +
+ +
+
Dont
+
+
    +
  • Give a toolbar select a literal width.
  • +
  • Put a ghost select inside a properties section.
  • +
+ +
+ + diff --git a/design_system/components/selection-controls.html b/design_system/components/selection-controls.html new file mode 100644 index 0000000..fa23c5b --- /dev/null +++ b/design_system/components/selection-controls.html @@ -0,0 +1,59 @@ + + + + + +Selection controls + + + + +
+ +
+
Selection controls
+
On or off. Checkbox or Switch, one per surface.
+
+ +
+
+
Checkbox
+ + +
+ +
+
Switch
+ + + +
+
+ + +
+
Use it for
+
+
    +
  • Checkbox in dense editor panels. Arcs and Angles uses it throughout.
  • +
  • Switch in rows on a product page. The hub uses it in settings rows.
  • +
+ +
+
Dont
+
+
    +
  • Mix checkboxes and switches on one surface.
  • +
  • Recolour the track, thumb or tick. The accent is primary.
  • +
+ +
+ + diff --git a/design_system/components/settings-list.html b/design_system/components/settings-list.html new file mode 100644 index 0000000..6561875 --- /dev/null +++ b/design_system/components/settings-list.html @@ -0,0 +1,74 @@ + + + + + +Settings list + + + + +
+ +
+
Product
+
What people see at checkout
+
+ +
+
+
NameRequiredShown on receipts
+
+
+ +
+
BillingHow often it charges
+
+
+ Monthly + +
+
+
+ +
+
Checkout link
+
+ +
+
+ +
+
PriceRequired
+
+
A paid product needs a price above zero
+
Buyers see this converted to their own currency at checkout.
+
+
+ + +
+
Use it for
+
+
    +
  • Forms on the page ground: products, coupons, clients.
  • +
  • Selects, buttons and icon buttons are ghost. Text fields are outline, read-only ones too.
  • +
  • "Required" goes in the row's action slot, beside the label.
  • +
  • Why a value was refused goes in the row's error slot, under the row.
  • +
+ +
+
Dont
+
+
    +
  • Use secondary fields here. With no card behind them, filled controls float.
  • +
  • Stretch a select or a button to a column. They keep their own width - only a text field fills its slot.
  • +
  • Write a sentence in the subtitle. That goes in the description.
  • +
+ +
+ + diff --git a/design_system/components/text-fields.html b/design_system/components/text-fields.html new file mode 100644 index 0000000..e03ac1a --- /dev/null +++ b/design_system/components/text-fields.html @@ -0,0 +1,98 @@ + + + + + +Text fields + + + + +
+ +
+
Text fields
+
Outline for free text, secondary for values.
+
+ +
+
+
Outline - the default
+ +
+ +
+
Outline, filled in
+ +
+ +
+
With a leading icon
+ +
+ +
+
Secondary
+ +
+ +
+
Read only
+ +
+ +
+
Numeric, centred, with a unit
+ +
+ +
+
Error - the outline reddens, never the fill
+ +
+ +
+
Outline, hovered
+ +
+ +
+
Read only, monospace - ids and skus
+ +
+
+ + +
+
Use it for
+
+
    +
  • Outline for free text: names, search, a composer, a form.
  • +
  • Secondary for numeric property values and read-only values.
  • +
  • Inside a property row the field is forced to secondary. You dont pick.
  • +
  • A read-only value is a read-only, disabled field. Never bare text.
  • +
+ +
+
Dont
+
+
    +
  • Hand-roll a red line under a field. Use the row's error slot.
  • +
  • Put a secondary field in a settings list, or an outline one in a properties section.
  • +
  • Colour or size the icon. The field does it.
  • +
+ +
+ + diff --git a/design_system/foundations/colors.html b/design_system/foundations/colors.html new file mode 100644 index 0000000..7f29a17 --- /dev/null +++ b/design_system/foundations/colors.html @@ -0,0 +1,140 @@ + + + + + +Colour + + + + + +
+
Colour
+
37 slots per scheme. Carbon is the default. Pick the slot by what the thing is.
+
+ +
+
Ground & surfaces
+
+
--background#0d0d0dPage ground, and a panel's fill
+
--chrome#010101Header and footer bars, the gutter between panels
+
--surface-sunken#0a0a0aOne step below the ground: rails, panel bodies
+
--card#1b1b1bRaised surface: cards, properties sections, dialogs
+
--muted#0f0f0fDe-emphasised fill
+
+
+ +
+
Text
+
+
--foreground#f2f2f2Default text
+
--muted-foreground#8f8f8fSecondary text, hints, units
+
--row-text#c2c2c2A row's resting label
+
--row-hovered#222222A hovered row in lists and menus
+
+
+ +
+
Controls
+
+
--control-fill#080808Shared fill of every control
+
--control-fill-hovered#070707Control fill, hovered
+
--control-fill-focused#000000Control fill, focused
+
--control-border#2a2a2aShared stroke of every control
+
--secondary#303030A neutral filled control
+
--secondary-hovered#424242Secondary, hovered
+
--secondary-foreground#f2f2f2Text on secondary
+
--switch-track-inactive#0f0f0fA switch while off
+
+
+ +
+
Floating
+
+
--popover#000000Menus, select popups, anything floating
+
--popover-border#1c1c1cA popover's edge
+
--popover-item-hovered#1f1f1fHover inside select and date popups
+
--tooltip-background#080808Tooltips, above everything
+
--tooltip-border#1f1f1fA tooltip's edge
+
+
+ +
+
Accent & state
+
+
--primary#e8e8e8The accent. The one thing that's on or chosen
+
--primary-hovered#fefefePrimary, hovered
+
--primary-foreground#000000Text on primary
+
--destructive#c43333Delete, revoke, discard
+
--ring#ffffffKeyboard focus
+
+
+ +
+
Lines & panels
+
+
--border#010101General outline
+
--divider#202020A seam between things
+
--panel-border#222222A panel's edge, resting
+
--panel-border-highlighted#3a3a3aThe lit panel's edge
+
--properties-section-border#272727A properties section's edge
+
+
+ +
+
Charts
+
+
--chart-1#f45b69Data visualisation
+
--chart-2#86d957Data visualisation
+
--chart-3#62a7ffData visualisation
+
--chart-4#ffc857Data visualisation
+
--chart-5#ff8c42Data visualisation
+
+
+ +
+
Other schemes, same slots in the same order
+
+ Light +
+
+
+
+
+
+
+
+
+
+
+ Dark +
+
+
+
+
+
+
+
+
+
+
+ + + diff --git a/design_system/foundations/density.html b/design_system/foundations/density.html new file mode 100644 index 0000000..c2e1c8e --- /dev/null +++ b/design_system/foundations/density.html @@ -0,0 +1,89 @@ + + + + + +Density + + + + + +
+
Density
+
Three tiers. Pick one for the app, don't tune it.
+
+ +
+
Same controls, three tiers
+
+ +
+
Compact
+
+
+
Monthly
+
+
+
Width
+
+
+
+ +
+
Normal
+
+
+
Monthly
+
+
+
Width
+
+
+
+ +
+
Product
+
+
+
Monthly
+
+
+
Width
+
+
+
+ +
+
+ + +
+
The numbers
+
+
CompactNormalProduct
+
font-size101012
+
control-height232733
+
chrome-bar-height313743
+
ForDense desktop toolsThe same, roomierWeb and product apps
+
Used byArcs & AnglesA&A "Comfortable"The hub
+
+
+ + + diff --git a/design_system/foundations/shape.html b/design_system/foundations/shape.html new file mode 100644 index 0000000..649364a --- /dev/null +++ b/design_system/foundations/shape.html @@ -0,0 +1,77 @@ + + + + + +Shape + + + + + +
+
Shape
+
Small radii, flat surfaces. Borders and surface steps do the separating.
+
+ +
+
Radii
+
+
radius-xs2
+
radius-sm4
+
radius-md6 · controls
+
radius-lg8
+
radius-xl10 · cards
+
radius-xxl12 · dialogs
+
+
+ + +
+
Panels
+
+
panel-radius10
+
panel-gap5
+
panel-border-width1.15
+
+
+ +
+
Flat, no shadows
+
+
Chrome
+
+
+
Rail
+
Sunken panel
+
+
+
Workspace
+
The lit panel. Only one at a time.
+
+
+
+
Chrome ground, panels standing on it, panel-gap gutters between.
+
+ + + diff --git a/design_system/foundations/spacing.html b/design_system/foundations/spacing.html new file mode 100644 index 0000000..81e104f --- /dev/null +++ b/design_system/foundations/spacing.html @@ -0,0 +1,80 @@ + + + + + +Spacing + + + + + +
+
Spacing
+
Gaps come from the density tier. Never a literal.
+
+ +
+
Gap ladder
+
+
+
Product (and normal)
+
gap-xxs
3
+
gap-xs
5
+
gap-sm
8
+
gap-md
10
+
gap-lg
15
+
gap-xl
20
+
gap-xxl
30
+
+
+
Compact
+
gap-xxs
2
+
gap-xs
4
+
gap-sm
6
+
gap-md
8
+
gap-lg
12
+
gap-xl
16
+
gap-xxl
24
+
+
+
+ + +
+
Heights and containers
+
+
+
Product
+
control-height
33
+
property-row-height
36
+
chrome-bar-height
43
+
container-gap
10
+
container-padding
20
+
+
+
Compact
+
control-height
23
+
property-row-height
28
+
chrome-bar-height
31
+
container-gap
8
+
container-padding
16
+
+
+
+ + + diff --git a/design_system/foundations/type.html b/design_system/foundations/type.html new file mode 100644 index 0000000..8ff944c --- /dev/null +++ b/design_system/foundations/type.html @@ -0,0 +1,99 @@ + + + + + +Type + + + + + +
+
Type
+
Geist for everything, Geist Mono for numbers and readouts.
+
+ +
+
Families
+
+
+ Geist + --font-sans. Body, controls, headings. +
+
+ Geist Mono 0123 + --font-mono. Readouts, ids, stats. +
+
+
+ + +
+
Size ladder
+
+
+
Product
+
text-lg 22Page title
+
text-sm 17Section heading
+
text-xs 13List text
+
font-size 12Body and controls
+
text-xxs 11Captions
+
+
+
Compact
+
text-lg 18Page title
+
text-sm 14Section heading
+
text-xs 11List text
+
font-size 10Body and controls
+
text-xxs 9Captions
+
+
+
+ +
+
Weights
+
+
AaBody 300
+
AaControl 400
+
AaMedium 500
+
AaSemibold 600
+
+
+ + +
+
Job to style
+
+
Page titleProductstext-lg
+
SubtitleWhat this page is formuted
+
Section headingBillingtext-sm, 600
+
Nav group captionAccounttext-xxs, 600, muted
+
Hint, unit, empty lineNothing here yetmuted
+
Mono readout60 fps · 12.4 msmono, muted
+
+
+ + + diff --git a/design_system/patterns/editor-shell.html b/design_system/patterns/editor-shell.html new file mode 100644 index 0000000..a19746f --- /dev/null +++ b/design_system/patterns/editor-shell.html @@ -0,0 +1,173 @@ + + + + + +Editor shell + + + + +
+ + +
+ +
+ 60 fps + + Northern loop.ana + +
+
+ + +
+ +
+
+ +
+ +
+ + +
+ + +
+
© 2026 IMBENJI.NET LTD - ARCS & ANGLES
+
X: 0 · Y: 0Zoom: 100%
+
+ Segments: 48 + Nodes: 112 + Stations: 36 + v2.4.0 +
+
+ +
+ + diff --git a/design_system/patterns/hub-form.html b/design_system/patterns/hub-form.html new file mode 100644 index 0000000..0ac7468 --- /dev/null +++ b/design_system/patterns/hub-form.html @@ -0,0 +1,135 @@ + + + + + +Hub form + + + + +
+
+ + + + + +
+
+
+
+
Pro licence
+
Something you sell, and what it goes for.
+
+ + +
+
+ +
+
Details
+
What it is and how its sold
+
+
+
+
NameRequiredWhat a buyer sees
+
+
+
+
SKUHow an app asks for it. Fixed once created
+
+
+
+
Use caseWhat's being sold, which sets the tax
+
Downloadable software
+
+
+
CheckoutWho takes the card details
+
Stripe Checkout
+
+
+
Key lifetimeSeconds. Empty inherits the project's
+
+
Has to be a whole number of seconds
+
+
+ +
+
Pricing
+
What it costs, and how often
+
+
+
+
+ +
Default price
What it goes on sale at
+
+
+
+
Price
+
GBP
+
+
+
Billing
+
Monthly
+
+
+
+
+
+ +
+
+ + diff --git a/design_system/patterns/hub-page.html b/design_system/patterns/hub-page.html new file mode 100644 index 0000000..4338628 --- /dev/null +++ b/design_system/patterns/hub-page.html @@ -0,0 +1,151 @@ + + + + + +Hub page + + + + +
+
+ + + + + +
+
+
+
Account
+
Your personal details and how you appear on Garage.
+
+ +
+
+ +
Account
Your names, sign-in email and account identifiers
+
+
+
+
Username
+
+
+
+ +
+
+
+
+
Display name
+
+
+
+
Email
+
+
+
+
Member since
+
+
+
+
User ID
+
+
+
+ +
+
+
+
+
+ +
+
+ +
Personal information
The name and details on your account
+
+
+
+
Legal name
+
+
+
+
Date of birth
+
+
+
+
+ +
+
+ +
Password
Used to sign in when a passkey isn't available
+
+
+
+
Password
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+ + diff --git a/design_system/patterns/settings-pane.html b/design_system/patterns/settings-pane.html new file mode 100644 index 0000000..5dcc227 --- /dev/null +++ b/design_system/patterns/settings-pane.html @@ -0,0 +1,112 @@ + + + + + +Settings pane + + + + +
+
+
+ + + +
+
+
+
Appearance
+
How the editor and the map are drawn.
+
+ +
+
+ +
Editor
Colours the interface itself is drawn in
+
+
+
+
Colour scheme
+
Carbon
+
+
+
Accent
+
Default
+
+
+
Dark mode
+
+
+
+
+ +
+
+ +
Interface
How much room a control takes up
+
+
+
+
Density
+
Compact
+
+
+
+ +
+
+ +
Canvas
The map's own colours, separate from the editor's
+
+
+
+
Colour scheme
+
Follow editor
+
+
+
Dark mode
+
+
+
+
+
+
+ +
+
+
+ + diff --git a/design_system/patterns/states.html b/design_system/patterns/states.html new file mode 100644 index 0000000..d7bd823 --- /dev/null +++ b/design_system/patterns/states.html @@ -0,0 +1,68 @@ + + + + + +Page states + + + + +
+
+ + +
+
+
Payments
+
What you've bought, and what you're still paying for.
+
+
+
+ + +
+
+
Projects
+
Applications you build and sell on Garage.
+
+
+
No projects yet
+
+
+ + +
+
+
Security
+
Your password, passkeys and signed-in devices.
+
+
+
+ +
Passkeys
Sign in with your device instead of a password
+
+
+
+
Couldn't load your passkeys.
+ +
+
+
+
+ +
+
+ + diff --git a/design_system/tokens.css b/design_system/tokens.css new file mode 100644 index 0000000..d97631d --- /dev/null +++ b/design_system/tokens.css @@ -0,0 +1,259 @@ +/* garage_ui design tokens. Generated by garage_ui/tool/export_design_tokens_test.dart + - dont edit by hand, rerun the export. + Scheme: data-scheme="carbon" (default) | "dark" | "light" + Density: data-density="product" (default) | "normal" | "compact" */ + +@import url("https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap"); + +:root { + --font-sans: "Geist", system-ui, sans-serif; + --font-mono: "Geist Mono", ui-monospace, monospace; + --weight-body: 300; + --weight-control: 400; + --weight-medium: 500; + --weight-semibold: 600; + --radius-xs: 2px; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + --radius-xxl: 12px; + --panel-radius: 10px; + --panel-gap: 5px; + --panel-border-width: 1.15px; + --property-split: 0.4; + --property-label-gap: 10px; + --dialog-max-width: 350px; +} + +:root, [data-scheme="carbon"] { + --background: #0d0d0d; + --foreground: #f2f2f2; + --chrome: #010101; + --surface-sunken: #0a0a0a; + --card: #1b1b1b; + --muted: #0f0f0f; + --muted-foreground: #8f8f8f; + --row-text: #c2c2c2; + --row-hovered: #222222; + --popover: #000000; + --popover-border: #1c1c1c; + --popover-item-hovered: #1f1f1f; + --tooltip-background: #080808; + --tooltip-border: #1f1f1f; + --primary: #e8e8e8; + --primary-hovered: #fefefe; + --primary-foreground: #000000; + --secondary: #303030; + --secondary-hovered: #424242; + --secondary-foreground: #f2f2f2; + --destructive: #c43333; + --control-fill: #080808; + --control-fill-hovered: #070707; + --control-fill-focused: #000000; + --control-border: #2a2a2a; + --switch-track-inactive: #0f0f0f; + --border: #010101; + --divider: #202020; + --panel-border: #222222; + --panel-border-highlighted: #3a3a3a; + --properties-section-border: #272727; + --ring: #ffffff; + --chart-1: #f45b69; + --chart-2: #86d957; + --chart-3: #62a7ff; + --chart-4: #ffc857; + --chart-5: #ff8c42; +} + +[data-scheme="dark"] { + --background: #303030; + --foreground: #e6e6e6; + --chrome: #171717; + --surface-sunken: #272727; + --card: #3d3d3d; + --muted: #1d1d1d; + --muted-foreground: #999999; + --row-text: #c1c1c1; + --row-hovered: #474747; + --popover: #191919; + --popover-border: #2d2d2d; + --popover-item-hovered: #424242; + --tooltip-background: #1d1d1d; + --tooltip-border: #303030; + --primary: #4772b3; + --primary-hovered: #6b8fc4; + --primary-foreground: #ffffff; + --secondary: #565656; + --secondary-hovered: #676767; + --secondary-foreground: #e6e6e6; + --destructive: #a9575f; + --control-fill: #1d1d1d; + --control-fill-hovered: #222222; + --control-fill-focused: #1a1a1a; + --control-border: #353535; + --switch-track-inactive: #1d1d1d; + --border: #171717; + --divider: #454545; + --panel-border: #464646; + --panel-border-highlighted: #606060; + --properties-section-border: #4c4c4c; + --ring: #6592d7; + --chart-1: #ff3352; + --chart-2: #8bdc00; + --chart-3: #2890ff; + --chart-4: #edba18; + --chart-5: #ed5700; +} + +[data-scheme="light"] { + --background: #f2f2f4; + --foreground: #1a1a1c; + --chrome: #cdcdd5; + --surface-sunken: #e7e7ea; + --card: #fefeff; + --muted: #d7d7de; + --muted-foreground: #65656d; + --row-text: #3b3b40; + --row-hovered: #fefeff; + --popover: #d1d1d8; + --popover-border: #ededf0; + --popover-item-hovered: #fefeff; + --tooltip-background: #d7d7de; + --tooltip-border: #f2f2f4; + --primary: #3a63a1; + --primary-hovered: #537fc1; + --primary-foreground: #ffffff; + --secondary: #fefeff; + --secondary-hovered: #fefeff; + --secondary-foreground: #1a1a1c; + --destructive: #b3474f; + --control-fill: #d7d7de; + --control-fill-hovered: #e0e0e4; + --control-fill-focused: #d3d3d9; + --control-border: #f9f9fa; + --switch-track-inactive: #d7d7de; + --border: #cdcdd5; + --divider: #d8d8de; + --panel-border: #d7d7dd; + --panel-border-highlighted: #fefeff; + --properties-section-border: #eaeaff; + --ring: #4880d5; + --chart-1: #ff3352; + --chart-2: #8bdc00; + --chart-3: #2890ff; + --chart-4: #edba18; + --chart-5: #ed5700; +} + +:root, [data-density="product"] { + --font-size: 12px; + --line-height: 1.25; + --line-box: 15px; + --control-height: 33px; + --control-padding-x: 10px; + --control-padding-y: 9px; + --control-gap: 5px; + --control-border-width: 1px; + --menu-row-height: 33px; + --popup-row-height: 33px; + --explorer-row-height: 29px; + --chrome-bar-height: 43px; + --property-row-height: 36px; + --label-column-height: 31px; + --properties-section-gap: 15px; + --explorer-row-base-padding: 8px; + --explorer-row-end-padding: 12px; + --list-row-indent: 10px; + --container-gap: 10px; + --container-padding: 20px; + --gap-xxs: 3px; + --gap-xs: 5px; + --gap-sm: 8px; + --gap-md: 10px; + --gap-lg: 15px; + --gap-xl: 20px; + --gap-xxl: 30px; + --text-xxs: 11px; + --text-xs: 13px; + --text-sm: 17px; + --text-lg: 22px; + --icon-sm: 12px; + --icon-md: 20px; + --icon-lg: 24px; +} + +[data-density="compact"] { + --font-size: 10px; + --line-height: 1.1; + --line-box: 11px; + --control-height: 23px; + --control-padding-x: 10px; + --control-padding-y: 6px; + --control-gap: 4px; + --control-border-width: 1px; + --menu-row-height: 20px; + --popup-row-height: 20px; + --explorer-row-height: 22px; + --chrome-bar-height: 31px; + --property-row-height: 28px; + --label-column-height: 26px; + --properties-section-gap: 6px; + --explorer-row-base-padding: 4px; + --explorer-row-end-padding: 10px; + --list-row-indent: 6px; + --container-gap: 8px; + --container-padding: 16px; + --gap-xxs: 2px; + --gap-xs: 4px; + --gap-sm: 6px; + --gap-md: 8px; + --gap-lg: 12px; + --gap-xl: 16px; + --gap-xxl: 24px; + --text-xxs: 9px; + --text-xs: 11px; + --text-sm: 14px; + --text-lg: 18px; + --icon-sm: 10px; + --icon-md: 20px; + --icon-lg: 24px; +} + +[data-density="normal"] { + --font-size: 10px; + --line-height: 1.1; + --line-box: 11px; + --control-height: 27px; + --control-padding-x: 12px; + --control-padding-y: 8px; + --control-gap: 5px; + --control-border-width: 1px; + --menu-row-height: 26px; + --popup-row-height: 26px; + --explorer-row-height: 30px; + --chrome-bar-height: 37px; + --property-row-height: 30px; + --label-column-height: 26px; + --properties-section-gap: 15px; + --explorer-row-base-padding: 8px; + --explorer-row-end-padding: 14px; + --list-row-indent: 12px; + --container-gap: 10px; + --container-padding: 20px; + --gap-xxs: 3px; + --gap-xs: 5px; + --gap-sm: 8px; + --gap-md: 10px; + --gap-lg: 15px; + --gap-xl: 20px; + --gap-xxl: 30px; + --text-xxs: 9px; + --text-xs: 11px; + --text-sm: 14px; + --text-lg: 18px; + --icon-sm: 10px; + --icon-md: 20px; + --icon-lg: 24px; +} + diff --git a/design_system/tokens.json b/design_system/tokens.json new file mode 100644 index 0000000..346866f --- /dev/null +++ b/design_system/tokens.json @@ -0,0 +1,256 @@ +{ + "_generated": "by garage_ui/tool/export_design_tokens_test.dart - dont edit by hand", + "fonts": { + "sans": "Geist", + "mono": "Geist Mono" + }, + "weights": { + "body": 300, + "control": 400, + "medium": 500, + "semibold": 600 + }, + "schemes": { + "carbon": { + "background": "#0d0d0d", + "foreground": "#f2f2f2", + "chrome": "#010101", + "surface-sunken": "#0a0a0a", + "card": "#1b1b1b", + "muted": "#0f0f0f", + "muted-foreground": "#8f8f8f", + "row-text": "#c2c2c2", + "row-hovered": "#222222", + "popover": "#000000", + "popover-border": "#1c1c1c", + "popover-item-hovered": "#1f1f1f", + "tooltip-background": "#080808", + "tooltip-border": "#1f1f1f", + "primary": "#e8e8e8", + "primary-hovered": "#fefefe", + "primary-foreground": "#000000", + "secondary": "#303030", + "secondary-hovered": "#424242", + "secondary-foreground": "#f2f2f2", + "destructive": "#c43333", + "control-fill": "#080808", + "control-fill-hovered": "#070707", + "control-fill-focused": "#000000", + "control-border": "#2a2a2a", + "switch-track-inactive": "#0f0f0f", + "border": "#010101", + "divider": "#202020", + "panel-border": "#222222", + "panel-border-highlighted": "#3a3a3a", + "properties-section-border": "#272727", + "ring": "#ffffff", + "chart-1": "#f45b69", + "chart-2": "#86d957", + "chart-3": "#62a7ff", + "chart-4": "#ffc857", + "chart-5": "#ff8c42" + }, + "dark": { + "background": "#303030", + "foreground": "#e6e6e6", + "chrome": "#171717", + "surface-sunken": "#272727", + "card": "#3d3d3d", + "muted": "#1d1d1d", + "muted-foreground": "#999999", + "row-text": "#c1c1c1", + "row-hovered": "#474747", + "popover": "#191919", + "popover-border": "#2d2d2d", + "popover-item-hovered": "#424242", + "tooltip-background": "#1d1d1d", + "tooltip-border": "#303030", + "primary": "#4772b3", + "primary-hovered": "#6b8fc4", + "primary-foreground": "#ffffff", + "secondary": "#565656", + "secondary-hovered": "#676767", + "secondary-foreground": "#e6e6e6", + "destructive": "#a9575f", + "control-fill": "#1d1d1d", + "control-fill-hovered": "#222222", + "control-fill-focused": "#1a1a1a", + "control-border": "#353535", + "switch-track-inactive": "#1d1d1d", + "border": "#171717", + "divider": "#454545", + "panel-border": "#464646", + "panel-border-highlighted": "#606060", + "properties-section-border": "#4c4c4c", + "ring": "#6592d7", + "chart-1": "#ff3352", + "chart-2": "#8bdc00", + "chart-3": "#2890ff", + "chart-4": "#edba18", + "chart-5": "#ed5700" + }, + "light": { + "background": "#f2f2f4", + "foreground": "#1a1a1c", + "chrome": "#cdcdd5", + "surface-sunken": "#e7e7ea", + "card": "#fefeff", + "muted": "#d7d7de", + "muted-foreground": "#65656d", + "row-text": "#3b3b40", + "row-hovered": "#fefeff", + "popover": "#d1d1d8", + "popover-border": "#ededf0", + "popover-item-hovered": "#fefeff", + "tooltip-background": "#d7d7de", + "tooltip-border": "#f2f2f4", + "primary": "#3a63a1", + "primary-hovered": "#537fc1", + "primary-foreground": "#ffffff", + "secondary": "#fefeff", + "secondary-hovered": "#fefeff", + "secondary-foreground": "#1a1a1c", + "destructive": "#b3474f", + "control-fill": "#d7d7de", + "control-fill-hovered": "#e0e0e4", + "control-fill-focused": "#d3d3d9", + "control-border": "#f9f9fa", + "switch-track-inactive": "#d7d7de", + "border": "#cdcdd5", + "divider": "#d8d8de", + "panel-border": "#d7d7dd", + "panel-border-highlighted": "#fefeff", + "properties-section-border": "#eaeaff", + "ring": "#4880d5", + "chart-1": "#ff3352", + "chart-2": "#8bdc00", + "chart-3": "#2890ff", + "chart-4": "#edba18", + "chart-5": "#ed5700" + } + }, + "densities": { + "compact": { + "font-size": 10.0, + "line-height": 1.1, + "line-box": 11.0, + "control-height": 23.0, + "control-padding-x": 10.0, + "control-padding-y": 6.0, + "control-gap": 4.0, + "control-border-width": 1.0, + "menu-row-height": 20.0, + "popup-row-height": 20.0, + "explorer-row-height": 22.0, + "chrome-bar-height": 31.0, + "property-row-height": 28.0, + "label-column-height": 26.0, + "properties-section-gap": 6.0, + "explorer-row-base-padding": 4.0, + "explorer-row-end-padding": 10.0, + "list-row-indent": 6.0, + "container-gap": 8.0, + "container-padding": 16.0, + "gap-xxs": 2.0, + "gap-xs": 4.0, + "gap-sm": 6.0, + "gap-md": 8.0, + "gap-lg": 12.0, + "gap-xl": 16.0, + "gap-xxl": 24.0, + "text-xxs": 9.0, + "text-xs": 11.0, + "text-sm": 14.0, + "text-lg": 18.0, + "icon-sm": 10.0, + "icon-md": 20.0, + "icon-lg": 24.0 + }, + "normal": { + "font-size": 10.0, + "line-height": 1.1, + "line-box": 11.0, + "control-height": 27.0, + "control-padding-x": 12.0, + "control-padding-y": 8.0, + "control-gap": 5.0, + "control-border-width": 1.0, + "menu-row-height": 26.0, + "popup-row-height": 26.0, + "explorer-row-height": 30.0, + "chrome-bar-height": 37.0, + "property-row-height": 30.0, + "label-column-height": 26.0, + "properties-section-gap": 15.0, + "explorer-row-base-padding": 8.0, + "explorer-row-end-padding": 14.0, + "list-row-indent": 12.0, + "container-gap": 10.0, + "container-padding": 20.0, + "gap-xxs": 3.0, + "gap-xs": 5.0, + "gap-sm": 8.0, + "gap-md": 10.0, + "gap-lg": 15.0, + "gap-xl": 20.0, + "gap-xxl": 30.0, + "text-xxs": 9.0, + "text-xs": 11.0, + "text-sm": 14.0, + "text-lg": 18.0, + "icon-sm": 10.0, + "icon-md": 20.0, + "icon-lg": 24.0 + }, + "product": { + "font-size": 12.0, + "line-height": 1.25, + "line-box": 15.0, + "control-height": 33.0, + "control-padding-x": 10.0, + "control-padding-y": 9.0, + "control-gap": 5.0, + "control-border-width": 1.0, + "menu-row-height": 33.0, + "popup-row-height": 33.0, + "explorer-row-height": 29.0, + "chrome-bar-height": 43.0, + "property-row-height": 36.0, + "label-column-height": 31.0, + "properties-section-gap": 15.0, + "explorer-row-base-padding": 8.0, + "explorer-row-end-padding": 12.0, + "list-row-indent": 10.0, + "container-gap": 10.0, + "container-padding": 20.0, + "gap-xxs": 3.0, + "gap-xs": 5.0, + "gap-sm": 8.0, + "gap-md": 10.0, + "gap-lg": 15.0, + "gap-xl": 20.0, + "gap-xxl": 30.0, + "text-xxs": 11.0, + "text-xs": 13.0, + "text-sm": 17.0, + "text-lg": 22.0, + "icon-sm": 12.0, + "icon-md": 20.0, + "icon-lg": 24.0 + } + }, + "shape": { + "radius-xs": 2.0, + "radius-sm": 4.0, + "radius-md": 6.0, + "radius-lg": 8.0, + "radius-xl": 10.0, + "radius-xxl": 12.0, + "panel-radius": 10.0, + "panel-gap": 5.0, + "panel-border-width": 1.15, + "property-split": 0.4, + "property-label-gap": 10, + "dialog-max-width": 350 + } +} \ No newline at end of file diff --git a/garage_ui/tool/export_design_tokens_test.dart b/garage_ui/tool/export_design_tokens_test.dart new file mode 100644 index 0000000..50c6b60 --- /dev/null +++ b/garage_ui/tool/export_design_tokens_test.dart @@ -0,0 +1,197 @@ +// Dumps the real theme values out to design_system/, so the design system +// never has to be typed out by hand (and never drifts from the package). +// +// cd garage_ui && flutter test tool/export_design_tokens_test.dart +// +// its a "test" only because thats the easiest way to get a flutter binding +// without an app. it writes files, it doesnt assert anything interesting. + +import "dart:convert"; +import "dart:io"; + +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:garage_ui/theme.dart"; + +String _hex(Color c) { + final argb = c.toARGB32(); + final a = (argb >> 24) & 0xff; + final rgb = (argb & 0xffffff).toRadixString(16).padLeft(6, "0"); + if (a == 0xff) return "#$rgb"; + return "#$rgb${a.toRadixString(16).padLeft(2, "0")}"; +} + +Map _colours(ColourScheme s) => { + "background": _hex(s.background), + "foreground": _hex(s.foreground), + "chrome": _hex(s.chrome), + "surface-sunken": _hex(s.surfaceSunken), + "card": _hex(s.card), + "muted": _hex(s.muted), + "muted-foreground": _hex(s.mutedForeground), + "row-text": _hex(s.rowText), + "row-hovered": _hex(s.rowHovered), + "popover": _hex(s.popover), + "popover-border": _hex(s.popoverBorder), + "popover-item-hovered": _hex(s.popoverItemHovered), + "tooltip-background": _hex(s.tooltipBackground), + "tooltip-border": _hex(s.tooltipBorder), + "primary": _hex(s.primary), + "primary-hovered": _hex(s.primaryHovered), + "primary-foreground": _hex(s.primaryForeground), + "secondary": _hex(s.secondary), + "secondary-hovered": _hex(s.secondaryHovered), + "secondary-foreground": _hex(s.secondaryForeground), + "destructive": _hex(s.destructive), + "control-fill": _hex(s.controlFill), + "control-fill-hovered": _hex(s.controlFillHovered), + "control-fill-focused": _hex(s.controlFillFocused), + "control-border": _hex(s.controlBorder), + "switch-track-inactive": _hex(s.switchTrackInactive), + "border": _hex(s.border), + "divider": _hex(s.divider), + "panel-border": _hex(s.panelBorder), + "panel-border-highlighted": _hex(s.panelBorderHighlighted), + "properties-section-border": _hex(s.propertiesSectionBorder), + "ring": _hex(s.ring), + "chart-1": _hex(s.chart1), + "chart-2": _hex(s.chart2), + "chart-3": _hex(s.chart3), + "chart-4": _hex(s.chart4), + "chart-5": _hex(s.chart5), + }; + +Map _density(Density d) { + final t = ThemeData(colorScheme: GarageSchemes.carbon, density: d); + return { + "font-size": d.fontSize, + "line-height": d.lineHeight, + "line-box": d.lineBox, + "control-height": d.controlHeight, + "control-padding-x": d.buttonPaddingX, + "control-padding-y": d.controlPaddingY, + "control-gap": d.controlGap, + "control-border-width": d.controlBorderWidth, + "menu-row-height": d.menuRowHeight, + "popup-row-height": d.popupRowHeight, + "explorer-row-height": d.explorerRowHeight, + "chrome-bar-height": d.chromeBarHeight, + "property-row-height": d.propertyRowHeight, + // the label + subtitle stack a SettingsRow is sized off + "label-column-height": d.labelColumnHeight, + "properties-section-gap": d.propertiesSectionGap, + "explorer-row-base-padding": d.explorerRowBasePadding, + "explorer-row-end-padding": d.explorerRowEndPadding, + "list-row-indent": d.listRowIndent, + "container-gap": d.containerGap, + "container-padding": d.containerPadding, + "gap-xxs": d.gapXxs, + "gap-xs": d.gapXs, + "gap-sm": d.gapSm, + "gap-md": d.gapMd, + "gap-lg": d.gapLg, + "gap-xl": d.gapXl, + "gap-xxl": d.gapXxl, + "text-xxs": d.textXxs, + "text-xs": d.textXs, + "text-sm": d.textSm, + "text-lg": d.textLg, + "icon-sm": t.iconTheme.small.size ?? d.iconSize, + "icon-md": t.iconTheme.medium.size ?? 20, + "icon-lg": t.iconTheme.large.size ?? 24, + }; +} + +String _num(num n) => n == n.roundToDouble() ? n.round().toString() : n.toString(); + +void main() { + test("export design tokens", () { + final out = Directory("../design_system"); + out.createSync(recursive: true); + + final t = ThemeData(colorScheme: GarageSchemes.carbon); + + final schemes = { + "carbon": _colours(GarageSchemes.carbon), + "dark": _colours(GarageSchemes.dark), + "light": _colours(GarageSchemes.light), + }; + final densities = { + "compact": _density(const Density.compact()), + "normal": _density(const Density.normal()), + "product": _density(const Density.product()), + }; + final shape = { + "radius-xs": t.radiusXs, + "radius-sm": t.radiusSm, + "radius-md": t.radiusMd, + "radius-lg": t.radiusLg, + "radius-xl": t.radiusXl, + "radius-xxl": t.radiusXxl, + "panel-radius": t.panelRadius, + "panel-gap": t.panelGap, + "panel-border-width": 1.15, + "property-split": 0.4, + "property-label-gap": 10, + "dialog-max-width": 350, + }; + + File("${out.path}/tokens.json").writeAsStringSync( + const JsonEncoder.withIndent(" ").convert({ + "_generated": "by garage_ui/tool/export_design_tokens_test.dart - dont edit by hand", + "fonts": {"sans": "Geist", "mono": "Geist Mono"}, + "weights": {"body": 300, "control": 400, "medium": 500, "semibold": 600}, + "schemes": schemes, + "densities": densities, + "shape": shape, + }), + ); + + final css = StringBuffer() + ..writeln("/* garage_ui design tokens. Generated by garage_ui/tool/export_design_tokens_test.dart") + ..writeln(" - dont edit by hand, rerun the export.") + ..writeln(" Scheme: data-scheme=\"carbon\" (default) | \"dark\" | \"light\"") + ..writeln(" Density: data-density=\"product\" (default) | \"normal\" | \"compact\" */") + ..writeln() + ..writeln("@import url(\"https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600;700&family=Geist+Mono:wght@400;500&display=swap\");") + ..writeln(); + + void block(String selector, Map vars) { + css.writeln("$selector {"); + vars.forEach((k, v) => css.writeln(" --$k: $v;")); + css.writeln("}"); + css.writeln(); + } + + block(":root", { + "font-sans": "\"Geist\", system-ui, sans-serif", + "font-mono": "\"Geist Mono\", ui-monospace, monospace", + "weight-body": "300", + "weight-control": "400", + "weight-medium": "500", + "weight-semibold": "600", + for (final e in shape.entries) + e.key: (e.key == "property-split") ? _num(e.value) : "${_num(e.value)}px", + }); + + schemes.forEach((name, vars) { + final sel = name == "carbon" ? ":root, [data-scheme=\"carbon\"]" : "[data-scheme=\"$name\"]"; + block(sel, vars); + }); + + // the default block has to come FIRST. `:root` and `[data-density=x]` are + // the same specificity, so whichever is later wins - with product last, + // quietly rendered product + final densityOrder = ["product", "compact", "normal"]; + for (final name in densityOrder) { + final vars = densities[name]!; + final sel = name == "product" ? ":root, [data-density=\"product\"]" : "[data-density=\"$name\"]"; + block(sel, { + for (final e in vars.entries) + e.key: e.key == "line-height" ? _num(e.value) : "${_num(e.value)}px", + }); + } + + File("${out.path}/tokens.css").writeAsStringSync(css.toString()); + }); +}