# Vibrant Gio — guide for AI assistants writing applications THIS FILE IS THE CANONICAL GUIDE for the whole github.com/vibrantgio organization. It lives at the root of the workbench repository and exists exactly once; every repo's AGENTS.md is a short static pointer to this URL, so this is the version to read and the version to edit: ``` https://raw.githubusercontent.com/vibrantgio/workbench/master/llms.txt ``` Vibrant Gio is a design system for native desktop apps (macOS/Windows/Linux) built on gioui.org, with a Functional Reactive Programming application model using github.com/reactivego/rx generics-based observables. This file tells a coding assistant everything needed to scaffold and build a correct Vibrant Gio application. The full architecture rationale is in DESIGN.md in github.com/vibrantgio/design; the apps in workbench (todos/, sitedocs/, feeds/, mindchat/, iconbrowser/, themer/, vaultview/, marketing/) are the canonical usage references — todos/ is the minimal complete app; read it first, and read the larger ones before inventing a pattern. THE SHORT VERSION, before the detail: the theme observable carries the whole look. Components take it and need nothing else — typeface, colour, density, elevation and motion all arrive through it. Application code reads tokens from the theme, never constructs a shaper (the theme's Typography owns the one shaper), and never writes a colour literal (colours come off the theme's seed-derived ramps). ## Modules and versions All modules live under github.com/vibrantgio/. Current tags: ``` mvu v1.0.1 MVU runtime: NewWindow, Loop/Run, Message/Command, MessageOp; Window.Option + OnConfigure, the re-assert seam the nested desktop module builds on (§Nested modules); Window.ViewEvents, the per-window stream of Gio view events the desktop module's drop target rides; subpackage stream (Value, the one sanctioned observable for state several consumers watch) font v0.4.2 Roboto, six weights, regular and italic — plus Roboto Mono, normal and bold each with italics, all OFL; TTFs embedded here, not eliasnaur.com/font. The default typography's faces. Also jetbrainsmono, the same four-face layout under "JetBrains Mono", packaged beside Roboto Mono and not in the default collection; a kept theme.json "mono" can name it for code. Also notosansmono, the OPTIONAL symbol face (arrows, box drawing, maths): it is NOT in DefaultTypography.Faces, because the default shaper falls back to the platform's fonts. Append it with Typography.WithFaces() only when you cannot rely on system fonts — a container, a kiosk. Also notocoloremoji, the OPTIONAL color-emoji face (Noto Color Emoji, CBDT/PNG): also not in DefaultTypography.Faces — 9.9 MB, and Gio's system fallback does not supply one. The live stream wears it via Typography.WithEmoji / EmojiTypography textdraw v0.0.5 low-level glyph text drawing backdrop v0.0.4 solid colour fill widget gradient v0.0.4 linear gradient fill widget circle v0.0.5 precise circle via Bezier style v0.0.7 FROZEN: the old MD2 type scale and FontFaces(); superseded by theme/tokens Typography (§Typography) — never add it to a new app theme v1.3.3 the theme runtime and every design token: tokens (colour ramps and pins, Typography, Density, Motion, Elevation, spacing/radius), color (the CIELAB/OKLCh engine and APCA), theme (Theme, the token observables), system (LiveTheme, palette injection, the OS accent), a11y (OS accessibility observables), export (token serialiser + cmd/vg-tokens), window, preferences, brand (the kept seed and optional "mono" code face, shared by every app). LiveTheme's default typography is EmojiTypography — the live stream wears Noto Color Emoji as fallback; goldens stay on DefaultTypography components v1.3.3 badge (the system's own word about a thing — a word, a count or a glyph, hued by role: Neutral, Success, Warning, Error, Info; a pale fill of the role's hue under a strong foreground of that same hue, plus an optional close mark that dismisses the label), button, chip (the small control content sprouts, defined by one of four purposes — Assist, Filter, Input, Suggestion), input, picker (pick one from many — a labelled field for the form variant, a Toolbar trigger for the chrome variant, one menu under both; input.Dropdown is a deprecated forwarder to it), list, richtext, scrollbar, scrollarea (a horizontal viewport for content that must not be reflowed — a code fence, an over-wide diagram), icon, icons (the design system's own marks), layout, keyed, initial, cache, bench (coordination is DEPRECATED — use mvu/stream.Value) effects v0.2.5 blur (Gaussian kernel, cached blur, backdrop pipeline), depth, glow, motion, spring, springbutton, transition, tween, conductor patterns v1.2.1 patterns: shell, navbar, sidebar, pane, table, pagination, tabs, modal, alert, popover, tooltip, toast, card, accordion, breadcrumb, hero, feature, pricing, testimonial markdown v0.7.2 GFM document rendering on components widgets (subpackages: highlight — chroma syntax colours; svgimage — SVG images via vibrantgio/svg; obsidian — the Obsidian dialect: frontmatter, wikilinks, block anchors) seen v0.0.9 3D scene graph rendered to SVG or Gio traer v0.0.9 particle physics (springs, attractions, Verlet) svg v0.1.0 SVG parse + render ivg v0.1.8 IconVG icons, Material icons bundled noise v0.0.4 Perlin and Simplex noise, 2D/3D kiwi v0.0.7 Cassowary constraint solver csg v0.0.2 constructive solid geometry on meshes (BSP trees) ``` workbench (the reference apps) and design (the published token bundle) carry no tags; consume them from the branch tip. ### Already deleted These do not exist in the tags above. If you find them in older code or an older answer, they are gone, not deprecated: - the alias packages prism/tokens, prism/theme and prism/a11y (import the theme/... path they used to forward to) and spectrum/transition (import effects/transition — that is where the colour-token tween lives, and it is why theme no longer depends on effects at all); - the MD3 colour aliases on ColorTokens: OnBackground, OnSurface, SurfaceVariant, OnSurfaceVariant, Outline. Each was a fixed resolution off the neutral ramp and nothing is unreachable — OnBackground was Text, OnSurface step 900, SurfaceVariant step 300, OnSurfaceVariant step 700, Outline step 500 (which is FocusRing()). §Colour has the replacements; - elevation levels 4 and 5 — the desktop elevation tops out at 3, and asking for a higher level now panics rather than clamping; - patterns/tag entirely, with no alias. The status label is components/badge (the roster above); a dismissible user-entered token is the Input chip's; - tokens.TypeScale entirely. The static `Render(…)` signatures on components, patterns and markdown take a role's `tokens.TextStyle` instead — or the whole `tokens.Typography` where a component spends several roles — plus a `tokens.Density` wherever a control is sized. Drive components through their theme-driven entry points (button.Button, table.Table, …) and the theme supplies all of it for you; - every cross-widget coordination bus cadence used to export: popover.Arbitration, popover.ArbitrationSnapshot, tooltip.Arbitration, tooltip.ArbitrationSnapshot, modal.Stack, modal.StackSnapshot and toast.Notifications. toast.Notify still exists but takes a layout.Context first — the compile error names the fix. See §Coordination. ### Renamed If you find these in older code, require the new path: - github.com/vibrantgio/spectrum → theme - github.com/vibrantgio/prism → components - github.com/vibrantgio/pulse → effects - github.com/vibrantgio/cadence → patterns ## Nested modules Eleven more modules live in subdirectories of the repos above. They do not show up in a repository listing and you will not guess them, but they are where the Gio rendering for several libraries actually lives. Their import path is the repo path plus the subdirectory, and — this is the part that trips up `go get` — their TAG carries the subdirectory as a prefix, so the tag for `github.com/vibrantgio/ivg/raster/gio vX.Y.Z` is `raster/gio/vX.Y.Z` in the ivg repo, not `vX.Y.Z`. The numbers below are generated, as above. ``` github.com/vibrantgio/ivg/raster/gio v0.1.8 Gio rasterizer + Widget() for IconVG github.com/vibrantgio/svg/driver/gio v0.1.0 SVG -> Gio ops, IconWidget() github.com/vibrantgio/svg/driver/raster v0.1.0 SVG -> raster image github.com/vibrantgio/svg/driver/pdf v0.0.7 SVG -> PDF github.com/vibrantgio/svg/driver/seen v0.1.0 SVG -> seen scene github.com/vibrantgio/seen/context/gio v0.0.9 Gio drawing context and widget for seen github.com/vibrantgio/traer/gio v0.0.9 Gio examples/render for traer physics github.com/vibrantgio/kiwi/gio v0.0.7 Gio layout on the kiwi solver github.com/vibrantgio/components/gallery v1.3.3 the components gallery app github.com/vibrantgio/mvu/desktop v1.0.1 native macOS window chrome (FullSizeContent, traffic-light unhide, queried top inset) on mvu's OnConfigure seam, title-band geometry and drag claims (CapTop, ButtonRunIn, DragTop), the application menu bar (NewMenuBar over a tree of MenuItem, choices arriving on Messages), and OS file drops (NewDropTarget, per-zone hit-testing, dropped paths arriving in Update as ordinary messages) github.com/vibrantgio/mvu/example v1.0.0 numbered mvu examples, 00-reference upward ``` CGO CAVEAT: mvu/desktop is darwin-only Objective-C behind `//go:build darwin`, with no-op stubs on every other platform, so it compiles everywhere but does its work only on macOS. Requiring it never adds cgo to mvu itself. Apply options through mvu's Window.Option, not the raw app.Window handle — options applied through the raw handle bypass the OnConfigure notification the chrome re-asserts itself on. The module's second tenant, file drops, sits behind the same darwin-only discipline: NewDropTarget(w, zones, kinds...) registers the mvu window to accept files dragged from the OS, resolves each drop against the zones the last frame recorded (ZoneGroup.Zone during layout; last-recorded wins on overlap), and delivers FilesEntered/FilesExited/FilesDropped into the loop as ordinary messages — no manual invalidate needed. File URLs are the one registered kind today; the kind parameter is MIME-shaped so other payloads can join without changing the surface. On non-mac platforms construction succeeds and nothing is ever delivered. The target rides mvu's ViewEvents stream to re-register whenever the native view is rebuilt, so it survives option changes the same way the chrome does — and that stream is single-subscriber: constructing a DropTarget claims it, so an application using drops must not subscribe ViewEvents itself. When you use a root and its nested module together, require them at matching versions: `seen` and `seen/context/gio` are the case you will hit first. External pins: gioui.org v0.10.2, github.com/reactivego/rx v0.3.0, go 1.25.1 — every module in the org is on exactly these. ## Application bootstrap (the canonical skeleton) Every Vibrant Gio app follows this shape (todos/main.go is the minimal live version; mindchat/main.go the production-sized one — the same shape, with its AutoConnect count documented consumer by consumer): ```go package main import ( "time" "gioui.org/app" "gioui.org/unit" "github.com/reactivego/rx" "github.com/vibrantgio/mvu" themesystem "github.com/vibrantgio/theme/system" themewin "github.com/vibrantgio/theme/window" ) func main() { go run() app.Main() // must be the last call on the main goroutine } func run() { mvuWin := mvu.NewWindow( app.Title("My app"), app.Size(unit.Dp(1100), unit.Dp(760)), ) // theme/window wraps the mvu window with a live OS theme: dark // mode, the OS accent colour and the accessibility preferences all // tracked at the given poll interval. Options brand it — see // §Branding for WithSeed/WithPalette. w := themewin.New(mvuWin, themesystem.LiveTheme(time.Second)) // Model observable: mvu.Loop scans Update over the window's // message stream merged with the messages emitted by the commands // Update returns (I/O, streams). Loop emits the seed model first; // multicast it to your layer topology with AutoConnect(nConsumers). models, runner := mvu.Loop(mvuWin.Messages(), Init, Update) defer func() { runner.Unsubscribe(); runner.Wait() }() modelObs := models.Publish().AutoConnect(nConsumers) if err := w.Render(buildLayers(modelObs)).Wait(); err != nil { // handle } } ``` ### MVU pieces you write A Model struct, message types, Init() (Model, mvu.Command), and Update(Model, mvu.Message) (Model, mvu.Command). Async side effects are mvu.Command values: mvu.Do(func() (mvu.Message, error)), mvu.DoNothing(), mvu.DoConcurrent(...), mvu.DoSequence(...). mvu.Loop runs them and feeds the messages they emit back into Update — one command may stream many messages (see mindchat/, whose OpenAI completion streams dozens of deltas from a single command). Effect-free apps return mvu.DoNothing() everywhere and never notice the runner. Commands are not the only valid effect seam — a write that must stay synchronous with the confirming click may sit in the submit CALLBACK instead, provided the reducer stays pure and callback and reducer route through the same pure helper so they cannot diverge — but every app here takes the command route: mindchat's config and history I/O and vaultview's vault-store write (openVaultCmd) are all mvu.Do. ## The five load-bearing architecture rules ### 1. The events thread is the heartbeat mvu's window subscribes the CombineLatest of all layers on an rx goroutine and stores each result as an atomic snapshot, then invalidates the window; the events goroutine reads that snapshot when the frame event arrives. So heavy work runs on rx goroutines, but values only reach rendering on a frame. Everything touching Gio ops runs single-threaded. Never call Gio from your own goroutine. ### 2. Interaction state lives in rx.Defer closures State allocated inside an rx.Defer factory runs once per subscription and is captured by reference in the Map and widget closures below it: ```go rx.Defer(func() rx.Observable[layout.Widget] { click := widget.Clickable{} // allocated once return rx.Map(inputs, func(v T) layout.Widget { return func(gtx layout.Context) layout.Dimensions { // read/mutate click here — events thread only } }) }) ``` Never put interaction state in rx.Subject — never put anything there; §Coordination is where the three replacements are. Never pass Defer-scoped state to a goroutine. For per-row state in dynamic lists use components/keyed: keyed.Defer(factory) returns a *keyed.Deferred[K,V] whose For(key) hands back the same value for the same key, so state survives reorder, insert and delete. Allocate the Deferred inside the rx.Defer closure, next to the other per-subscription state. It is sticky — a removed key's value is retained and reused if the key comes back; call Sweep(activeKeys) on a long-lived list with high churn. ### 3. Widgets emit messages via MessageOp Inside any widget callback with a gtx, route events into the MVU loop with: ```go mvu.MessageOp{Message: SelectItem{ID: id}}.Add(gtx.Ops) ``` The runtime extracts these from the ops buffer and delivers them to Update. Patterns/components component callbacks receive gtx for exactly this purpose. This is the bridge between components and MVU, and it is the answer for anything a test, a model dump or a command goroutine would want to see — never a side channel out of the layout pass (§Coordination). Modal forms: components input.TextFieldProps.Seed pre-fills an uncontrolled field so an existing value can be EDITED (rebuild the field keyed on an epoch to reseed); TextFieldProps.FocusTag exposes the editor's focus tag, and patterns/modal's DynamicFocusTags puts it in the Tab cycle (first tag gets initial focus on open). patterns/modal sizes its surface to the content (see mindchat's rename modal for the full recipe). Props.HideClose is DEPRECATED; say Props.Decision instead — see the dialog grammar in §Layers. ### 4. Publish().AutoConnect(N) counts are load-bearing The model observable is multicast WITHOUT replay; AutoConnect(N) connects the upstream scan only when the N-th subscriber attaches, which is what lets the seed model (emitted first by mvu.Loop) reach everyone. N must equal the exact number of cold subscriptions your layer topology makes — too low and late consumers miss the seed (blank UI), too high and the app freezes. Keep N static: never subscribe the model observable inside a keyed/per-row Defer (it attaches after the seed fired — the row sees a zero Model). Instead let the parent layer subscribe one eager mirror and share it as a `func() Model`. Write a test that measures the subscription count (see feeds/wiring_test.go). ### 5. Animation self-schedules and idles Animated widgets tick their simulation inside the frame and request the next frame only while active: ```go activity := ps.Tick(step) if activity > 0.01 { gtx.Execute(op.InvalidateCmd{}) } ``` Invalidation is window-global (every widget re-lays-out), so expensive widgets should cache ops when inputs are unchanged (components/cache). Respect reduced motion: while the OS preference is on, the theme's Motion observable emits zero durations and animated components snap to their targets — take durations and springs from the theme's MotionScale and the preference costs you nothing (§Motion). ## Coordination between widgets Two widgets that must agree — which popover is open, which modal is in front, what the filter says, that a toast should appear — have exactly three places to do it, and `rx.Subject` is not one of them. Pick by asking who needs to know, not by how important it feels: 1. ANYTHING OUTSIDE THE FRAME NEEDS TO KNOW → A MESSAGE, in the model. A test would assert on it, a model dump would show it, a command goroutine would raise it. Emit `mvu.MessageOp` during layout (rule 3 above) and reduce it in Update. Toast requests, filter text, sort, page, anything persisted. 2. ONLY THIS FRAME NEEDS TO KNOW → A PLAIN VALUE THE FRAME OWNS, written and read during layout. No mutex, no atomics, no observable: Gio runs one frame on one goroutine, so the hazard a synchronised bus guards against cannot arise. Popover, tooltip and modal arbitration; per-row open flags. 3. SEVERAL CONSUMERS WATCH ONE CURRENT VALUE → AN OBSERVABLE, and exactly one primitive: `mvu/stream.Value[T](seed)`, which returns the observer and the observable. The theme, the OS preferences, the user's saved settings. It CONFLATES — a slow consumer converges on the newest value and never blocks the producer — so if every emission is load-bearing you are at destination 1, not here. ### What this looks like in patterns Arbitration is a plain value you create and pass in, and the value IS the scope: ```go popArb := popover.NewArbiter() // one per WINDOW, in the build fn tipArb := tooltip.NewArbiter() mdlArb := modal.NewArbiter() popover.Popover(th, popover.Props{Arbiter: popArb, ...}) ``` - A nil Arbiter is legal and means ARBITRATE ALONE — the widget gets one of its own. Two popovers that both forget one stay open together. There is no package-level default any more; sharing is an act. - popover.Props.OpenNow (`func() bool`) is read during layout, so a flag the frame owns opens the popover on the frame it flips. Props.Open (an observable) is still right when the flag lives in the model — a non-nil OpenNow wins. - Toasts are model state: `toast.Requested{Level,Text}` / `toast.Expired{ID}` are messages, `toast.Queue` is the field you reduce onto, `toast.Expire(id, lifetime)` is the command that arms expiry, and `toast.Stack` takes them through `Props.Toasts`. Raise one with `toast.Notify(gtx, level, text)` inside a callback, or `toast.Request` where there is no frame. A Stack handed no Toasts renders an empty column forever and nothing fails at build time. ### Two smells, one of which no tool will find for you - A bare `rx.Subject`. It leaks a subscription slot for the life of the process (the 33rd subscriber dies with "out of subject subscriptions") and a departed subscriber's frozen cursor pins the producer — on the frame goroutine, that is a hung app. Use `mvu/stream.Value`. components/coordination is DEPRECATED. - AN EXPORTED OBSERVABLE THAT NOTHING SUBSCRIBES TO. Mechanism looks identical whether or not anyone is listening. The first question about an exported observable is not what publishes to it but who reads it. ## Layers and composition w.Render(build) takes a function from the theme observable to the layer stack — one or more rx.Observable[layout.Widget] values, stacked back-to-front (background first). A typical app has a backdrop layer and a content layer built from a patterns/shell: - patterns/shell: application shells in four variants — SidebarHeaderMain, SplitPane (draggable divider, either axis), ThreeColumn (resizable aside), StackedPage (pinned navbar over a shell-owned section scroll, for marketing pages; sections are widget streams, ContentMaxWidth caps the column). - patterns/navbar + patterns/sidebar fill the shell's slots. - Content composes patterns/table (sortable/filterable/virtualised), patterns/tabs, patterns/card, patterns/pagination, ... - Overlays: patterns/modal (focus trap, escape), patterns/popover (anchored), patterns/tooltip (hover/focus), patterns/toast (transient stack), patterns/alert (inline banners). A CHIP IS DEFINED BY ITS PURPOSE, never by its looks: components/chip is the small control content sprouts, and Props.Purpose names which of the four it is. Assist offers a contextual action; Filter narrows a set and is the only one that toggles; Input is a token the reader entered and carries a dismiss mark; Suggestion is a generated prompt. One structure under all four — [mark] label [dismiss], both brackets decided by the purpose rather than by a flag — one silhouette, one height (Density.ChipHeight, the density's control height less 4 dp). A chip is not a button at low prominence. A button is a fixture the author places, always there, always offering the same action; a chip appears out of content and context, and a button's label stays a verb. MARKING A CHOICE IS THE FILTER CHIP'S JOB AND NO BUTTON'S, whatever the button's emphasis: the button's Filled/Tonal/Ghost variants are a colour axis and record no state. COLOUR ARRIVES WITH MEANING. The resting chip has no fill at all — it is the outline OutlineVariant around OnSurfaceVariant ink over the surface you name (Props.Level) — and a selected Filter chip fills with the secondary container and drops that outline. Both are derived against the surface the chip stands on rather than copied from a fixed tone, so a brandless palette yields a brandless chip. Props.Pin puts the chip on the leading or trailing edge of a box wider than it and reports the box, for the container that reserved a cap but cannot place the chip inside it itself. It is not components/badge, which is the system's own word about a thing — Neutral, Success, Warning, Error, Info — set at the size of its type, wearing a pale fill of the role's hue under a strong foreground of that same hue, and never clicked. And it is not the picker's Toolbar trigger, which names a choice and stands over a list of the alternatives. ### The dialog grammar A modal is a PANEL or a DECISION, and you say which by leaving patterns/modal's Props.Decision nil or setting it — never by setting affordances one at a time. A panel (nil) applies its changes live and gets a ghost close X, a dismissing backdrop and Escape; a decision (non-nil) gets right-aligned footer Actions, Return on Decision.DefaultAction, Escape on Cancel, no X, and an inert backdrop. A footer with Save/Cancel means you have a decision, not a panel with HideClose (deprecated). Return never reaches a destructive primary: Decision derives the default from Confirm+Destructive, so there is no field with which to bind it wrongly. THE ACCELERATOR IS NOT THE MODAL'S: ⌘,/Ctrl-, must be live when no dialog exists, so bind it in app chrome with Gio's key.ModShortcut (never a GOOS test) over a window-wide pointer.PassOp key area, landing a message that opens the panel. Reference implementation: workbench/feeds shortcut.go + preferences.go. ### The theme carries the whole look Every component takes the theme observable for visual configuration and props structs for content, and the theme carries the whole look: colour, typography, density, elevation and motion all arrive through it, so a correct app wires the theme once and styles nothing per component. theme/tokens supplies the typed design values (colour ramps, a 4-pt spacing scale, named radius stops); never use string-keyed style maps. components/list can render a visible scrollbar via list.LayoutScrollbar (anchor Occupy reserves a right gutter, Overlay floats the bar over the trailing edge; default style from scrollbar.FromTokens). Effects components are explicit VARIANTS of components widgets, e.g. effects/springbutton wraps components/button with physics-driven press/release — opt in per call site, never a global decorator. Widget closures that run outside any rx scope (static component slots, table cell closures, navbar widgets) cannot subscribe the theme lazily — they would miss the current emission. The reference apps solve this with a small adapter that subscribes the theme's token streams once into an atomic cell and hands frame-time code a loader func: see mirrorTokens in workbench feeds/app.go, and the same hand-off in vaultview; sitedocs stores whole tab-content widgets through the same kind of cell for its static tab slots. ## Colour: one seed, ramps, pins, step walks COLOUR IS DERIVED, NOT PICKED. One seed colour generates the entire palette — both modes — through the CIELAB/OKLCh engine in theme/color: ```go light, dark := tokens.FromSeed(seed) // paired ColorTokens; the light // Primary pin is the seed exactly ``` ### Where a seed comes from When it is not typed, theme/imageseed.Extract takes an image.Image and returns candidate seeds — pixels sampled on a stride so a 20 Mpx photo costs what a thumbnail costs, clustered in OKLab, ranked by share times chroma squared so a vivid tenth of a picture outranks a drab half. Every candidate is a pixel the image really has, so it is in gamut and FromSeed takes it as it stands. Greyscale in, greys out. Application code never writes a colour literal. Every colour you need is on the tokens.ColorTokens value the theme's Color observable emits. The vocabulary: ### Ramps ColorTokens.Ramps holds five roles — Neutral, Primary, Secondary, Tertiary, Error — each a nine-step Ramp addressed Step(100)..Step(900), where THE STEP IS THE MEANING: 100–300 tinted fills, hovers and subtle borders; 500 the mid reference and strong border; 700–900 text over tinted fills and pressed states. Light and dark are PAIRED scales: the same step keeps the same job in both modes, so neutral 200 is a light card on a light ground and a dark card on a dark one — there is no second table and no dark-mode special case. ### Pins A brand colour rarely sits on the shared lightness scale, so each accent role's solid fill is pinned separately from its ramp: ColorTokens.Primary/Secondary/Tertiary/Error, each with an On* colour guaranteed readable over it. Background and Text are pins too. ### The semantic layer Background (app ground), Surface (card — neutral 200), Divider (subtle border — neutral 300), Text (body text over Background). Reach for these first; reach into the ramps when you need a specific step. ### Ink versus fill A PIN IS SAFE AS A FILL AND UNPROVEN AS AN INK. FromSeed guarantees every pin's On* colour reads over it — that direction is solved — but says nothing about the pin drawn ON a ground instead of under one, and only the light Primary pin can actually fail there: the rest sit at fixed perceptual depths that always clear. Whenever a brand colour is the ink rather than the fill — a link, a blockquote's bar, a task-list tick (§Markdown has the checkbox split) — derive it instead of reading the pin raw: ```go ink := c.InkOn(tokens.RolePrimary, ground, tokens.TextFloor) // words, 4.5:1 mark := c.InkOn(tokens.RolePrimary, ground, tokens.GraphicFloor) // a mark, 3:1 ``` InkOn returns the pin untouched once it already clears the floor against ground, so the canonical brand — which always clears — comes back unchanged; only a pale seed's Primary pin ever walks the ramp instead. None of this touches a pin used as a FILL: with something else drawn on top of it, c.Primary is already entitled to be its own colour, because it is what that something else's On* colour is measured against, not the other way round. ### States are step walks, not alpha overlays Hover is one step past the component's ground, pressed/selected/dragged two, clamped at 900 — resolved by StateColor (tinted surfaces) and SolidStateColor (pinned fills, which walk toward the 900 depth). Disabled is an opacity (tokens.Disabled, MD3's 38%). Focus keeps the surface and strokes a ring in the primary hue, walked rather than named: the rung of the primary ramp nearest that ramp's mid-value step which clears 3:1 against every level at once. That is ONE RING COLOUR PER SCHEME, and it does not depend on where the control stands — the same purple on the paper, on a card, in a dialog and in a popover, because the ring is the system's mark of focus rather than a property of the fill under it. The one exception is the band a filled control insets in its OWN fill: there the scheme's ring is kept wherever it reads against that fill, and walked against the fill only where it cannot. ColorTokens.FocusRing() is still on the token set and is still neutral 500, but nothing strokes focus with it any more — one fixed step measures 2.35:1 on a surface, 1.42:1 three levels up, and 1.00:1 around an unchecked checkbox, whose border is that same step. ```go // c is the tokens.ColorTokens the theme's Color observable emitted. alias := c.Surface // neutral 200 — a ramp alias, NOT a level hover := c.StateColor(tokens.RoleNeutral, 200, tokens.StateHover) // 300 pressed := c.StateColor(tokens.RoleNeutral, 200, tokens.StatePressed) // 400 label := c.Ramps.Neutral.Step(700) // low-contrast text body := c.Ramps.Neutral.Step(900) // body text accent := c.Primary // the pinned brand base accHov := c.SolidStateColor(tokens.RolePrimary, tokens.StateHover) ``` ### The generator guarantees contrast The generator guarantees contrast in APCA terms, in both modes: step 900 reaches Lc 90 and step 700 Lc 60 over the 100/200 grounds, and every pin's On* colour reaches Lc 60 over its pin. So neutral 900 on Surface is always body-text safe and neutral 700 always label-safe — by construction, not by review. (WCAG 2 ratios are reported alongside but do not gate; APCA is the gate because WCAG 2 over-rates light-on-dark pairs.) ## Branding: palette injection and the OS accent The default theme streams — theme.Default(), theme.AutoLightDark and theme/system's LiveTheme — emit the default seed's palette. Options on LiveTheme/FromSourceTheme brand an app without giving up live OS tracking: ```go // one brand colour; everything else derived, dark mode still live themesystem.LiveTheme(time.Second, themesystem.WithSeed(brand)) // full control: both schemes supplied, OS still picks which is live themesystem.LiveTheme(time.Second, themesystem.WithPalette(light, dark)) ``` Precedence, highest first: WithSeed/WithPalette pin the pair — the app chose its brand, the OS accent is ignored. With NO palette option the stream follows the OS accent colour live: macOS's accent choice (including graphite; multicolour means "no accent"), the Windows DWM accent, GNOME's named accent or KDE's kdeglobals RGB each become the seed of a derived pair, cached per seed. No accent at all falls back to the default palette. An accent change re-emits the theme just like a dark-mode flip. ### Accessibility composes on top of whichever palette wins While the OS reports increased contrast, the Color observable emits a high-contrast variant derived from the resolved palette's own seed (deeper text steps, divider from the strong-border step, stronger On* separation — same APCA gates, higher floors); while it reports reduced motion, Motion emits zero durations (§Motion). Apps do nothing to get either. ### The persisted choices theme/preferences persists an explicit in-app theme choice (light/dark/ auto + a11y overrides) as JSON in the OS config dir, when an app offers its own control; interpreting the stored name is the app's job. theme/brand is the other persisted choice, and it is per USER, not per app: one seed colour in one shared file (/vibrantgio/theme.json), so a brand chosen once is worn by everything. The same file may name a code face as "mono": "JetBrains Mono" or omitted/empty/unknown, which is Roboto Mono — the same fallback "base" already uses. Adopting it is one line — ```go specsystem.LiveTheme(interval, brand.Kept().Options()...) ``` — and with no file, or a damaged one, Options() is nil and the stream is the one it always was. The pinned pair beats the OS accent; light/dark still follows the desktop, live. An app that keeps a pre-emission fallback palette (an atomic cell read at frame time) must seed it from brand.Kept().Colors(), or its opening frames are in a colour nobody chose. Goldens stay on the canonical palette: adoption is runtime, never baked in. ## Density Desktop density is a theme token, not a per-component prop. tokens.Density carries the drawn control height and inner padding: ``` Comfortable 36 dp control, 16/8 dp padding (the default) Compact 28 dp control, 12/6 dp padding ``` The numbers are measured, not invented — shadcn/ui's h-9/h-8 next to macOS's control sizes; the derivation table lives in theme/tokens density.go. Components and patterns read Density from the theme: buttons, inputs, checkboxes, list rows, table rows, navbar, sidebar items, tabs and pagination all size from it, so switching an app to Compact is a theme change, not a sweep. The theme's Density observable defaults to Comfortable; there is no LiveTheme option for it — an app that wants Compact swaps the field on the stream: ```go th := rx.Map(themesystem.LiveTheme(time.Second), func(t theme.Theme) theme.Theme { t.Density = rx.Of(tokens.Compact) return t }) ``` The WCAG 2.5.5 pointer target (44 dp) is deliberately NOT part of Density: Density.MinHitTarget() is a constant, and components extend their pointer area beyond the drawn control to meet it — Compact shrinks the pixels, never the clickable area. Don't fight this: a 28 dp Compact button really does accept clicks a few dp outside its bounds, and neighbouring controls' slop overlapping is by design (the topmost input area wins). ## Elevation: the surface levels A raised surface reads as raised by TINT first, shadow second. There are SIX levels, counted from the backdrop up, anchored on the Background pin and placed in CIELAB L* so that a level nearer the viewer is lighter in BOTH schemes: ``` LevelBackdrop the bare window plane, showing wherever nothing stands darkest in the window LevelChrome the window's furniture — navbar, toolbar, sidebar, inspector, status bar, pane Level0 the content surface (the Background pin) Level1 card, fence, field — raised in place Level2 dialogs, toasts, higher panels Level3 popovers, menus — the topmost level ``` ```go surface := c.SurfaceAt(tokens.Level1) // resolves the fill per mode ``` DO NOT READ A LEVEL OFF THE NEUTRAL RAMP. A level is a ramp step only where the two happen to coincide: the light scheme's chrome level and backdrop land on neutral 200 and 300, and the dark scheme's three levels above the content land on neutral 200/300/400, but the light scheme's raised and floating fills are whispers toward white that no ramp step names. c.SurfaceAt is the only correct way to ask, and c.Surface is a RAMP ALIAS for neutral 200 rather than a level. In the light scheme the steps between levels are fractions of an L*, so anything that takes a level owes itself a derived hairline or a border to be seen at all. Because the ramps are paired, a surface tints per scheme with no second rule — dark-mode surface tint, the thing MD3's tonal elevation existed to encode, falls out for free. WHICH WAY a surface moves is not the pairing's to decide: a surface nearer the viewer is lighter, in BOTH schemes, which is the window-anatomy rule below and what the steps in the table answer to. State walks compose on top with the level's OWN FILL as the ground: c.StateAt(level, state) answers a level's hover and active, walking from the fill SurfaceAt gives rather than from a ramp index — so a control at the chrome level has an answer as much as one on a card. Ghost buttons obey the same rule from their HOST surface's rung: button Props carry a Level elevation level (zero value — the window ground, so existing call sites keep their colors), and the ghost hover and press washes walk one rung from that local ground rather than from the window's. Without it, a ghost control on a level-2 surface would hover to the very color it sits on and disappear. The patterns that put ghost controls on raised surfaces — modal's close X, popover, toast — pass their level for you. Levels 4 and 5 are GONE — they used to clamp onto level 3; asking for one now panics. Desktop has no six-deep stack. ### Shadows are opt-in Shadows survive as OPT-IN vibrancy via effects/depth, and the verdict on when is recorded in that package's doc: a shadow marks what FLOATS AND CAN LEAVE — a toast, a popover, a menu, a drag preview — never what is raised in place, which reads as raised by its surface step alone. The cost backs the rule: a depth.Shadow is nine paint ops per frame, a surface step is one FillShape. Do not add a shadow to a card. ## Window anatomy: which region wears which rung Elevation says how high a surface stands. This says WHICH PART OF A WINDOW STANDS WHERE — the assignment every app used to re-derive, some of them backwards — and WHICH WAY THE LIGHT COMES FROM. THE ONE RULE: in BOTH schemes, a surface nearer the viewer is LIGHTER. There is no second rule for dark mode and no mirror. Elevation reads as elevation because a surface nearer the viewer catches more light, and reflectance does not invert when the room goes dark; what a dark scheme inverts is the ink, which the paired ramps already handle. The consequence that reorganises everything else: CHROME IS A LEVEL OF ITS OWN, UNDER THE CONTENT rather than above it. A sidebar stands beside the document and beneath it, so it is DARKER than the document — in the light scheme and in the dark one. Five levels, ordered from the backdrop up toward the reader: ``` backdrop nothing: the bare window plane, showing wherever nothing stands darkest in the window chrome the window's furniture — navbar, toolbar, sidebar, inspector, status bar, pane a tint lighter than the backdrop content the document, the transcript, the list the Background pin raised filled insets on the content — cards, code fences, a band's controls, a text field above the pin floating what appears and leaves — dialogs, menus, popovers, toasts nearest the light extreme ``` Read that down and lightness increases, in both schemes. That is the whole model. Measured, so you can check it yourself. A desktop application in both appearances: sidebar 251,251,249 → content 252,252,251 → composer 255,255,255 in light, sidebar 17,17,17 → content 21,21,21 → composer 32,32,31 in dark. The platform's own settings window in dark: sidebar #1C2123 under content ground #23292C under setting cards #2A2F32, with a search field raised on the sidebar at #2F3233. Monotonic in every one of them, in the same direction. Note the sizes: in the light scheme the steps are fractions of an L* and the derived hairlines do the visible separating, so do not expect a light window's levels to stand far apart. - THE PAPER IS THE CONTENT GROUND, AT LEVEL 0. The largest resting expanse, the thing the window exists to show, fills with the Background pin — c.SurfaceAt(tokens.Level0). That is why level 0 is a sentinel rather than a ramp step and why a button's Level prop calls its zero value the window ground. - FURNITURE IS THE CHROME LEVEL, ONE MEASURED STEP UNDER THE CONTENT. Sidebars, asides, rails, toolbars and inspectors fill DARKER than the content — one step below it, in BOTH schemes, because they stand under the document rather than on top of it. Chrome is window-scale only: the trim inside a component or a pattern — a card's header, a dialog's footer, a table's header row — is that thing's structure and takes no level of its own. One small step is the whole separation between chrome and content; two is a mistake in either direction. THAT STEP IS MEASURED OFF THE PLATFORM, PER SCHEME, AND THE TWO SCHEMES DO NOT MEASURE THE SAME. A light window separates its furniture by about 4.9 L* — which is also the neutral ramp's own first surface interval, so the light chrome level lands on neutral 200 — and a dark window by a whisper, about 1.5: the platform's voice recorder puts its sidebar panel at #1B1B1B under #1E1E1E content, a reference chat application 1.71 L*, the platform's settings window 3.81 with its wallpaper tint on. A full ramp step in the dark scheme is nearly 5 L* and reads as a hole rather than as furniture. Do not derive either number and do not mirror one into the other scheme: resolve the level through the theme (c.SurfaceAt) rather than reaching for a raw ramp step, so a window inherits both measurements instead of restating one. The BACKDROP is a further step under the chrome — derived rather than measured, because no platform capture shows a window plane beneath its furniture. Nothing is drawn at it: it shows wherever nothing stands, which in a fully dressed window is nowhere. - A FLOATING CHROME PANE IS STILL CHROME. Chrome's depth is SEMANTIC, not geometric: a sidebar a button slides out of the window, an inspector that detaches, is still furniture and still fills at the chrome level. It does not climb the levels by leaving the wall. What says a pane is a floating object is its OWN HAIRLINE EDGE and its shadow, never a lighter fill — the platform paints even the floating panel darker than the content beside it, and outlines it internally at a ~1.5:1 whisper, a decorative seam rather than a 3:1 mark. Integral furniture — fixed, flush, unable to slide — takes no outline; its boundary is a plain seam. Draw a sliding pane with an edge and let it stay at the chrome level. And do not re-derive that object per window: patterns/pane IS the floating pane — the inset from the leading, top and bottom edges, the rounded corners, the hairline drawn inside them, a top strip cut deep enough to hold the window buttons on their own centre line, and a dismissed state that takes no width at all so what stood beside it reflows from the window's own leading edge. - NOTHING RESTING TAKES A FLOATING LEVEL. Levels 2 and 3 belong to what appears and leaves — a dialog and a toast's base at 2, a popover and a dropdown at 3, all of which the patterns pass for you — and to edges: Divider and the state walks. Permanence is the test and size is its tell. If a fill is the biggest thing on screen and never goes away, it is the content or the chrome, whatever it is called. - LEVELS WALK FROM THE SURFACE A THING STANDS ON, not from the window, and A STEP TOWARD THE VIEWER IS A STEP TOWARD THE SCHEME'S LIGHT EXTREME. A card on the content is one level up from it; a control inside a dialog walks from the dialog's own fill, which is what the Level prop exists to say. An inset inside a body that is FILLED — a code fence, a callout with a surface — steps UP from the paper it lies on rather than reaching for an absolute step, and UP means LIGHTER in both schemes. An inset that is MARKED instead — a blockquote's bar and muted ink, a rule — stands off its page by contrast, owes no rung and gets none; the levels are for fills. The content answers differently, because the Background pin is off the ramp and has no step to walk from: draw a raised thing on the content at the level above the pin — c.SurfaceAt(tokens.Level1) — and start its state walk there. And a shared surface takes its ground as a parameter, because it cannot know it: a pattern that paints a plane and then a band over it — a table's grid and its header, a tab panel and its strip — walks that band from the plane's own level, never from an absolute step, which is right only for as long as every caller happens to rest where it was written. - A FILLED INSET IS RAISED, NEVER A RECESSED WELL. There is no recessed class: the only test the system has for what owes a rung is FILLED versus MARKED, and it already puts the fence among the fills. So a fenced code block is LIGHTER than the page it lies on in both schemes, with its hairline border and its corner radius carrying the visible edge — which is what the desktop applications this system is judged against measure in both appearances (page #151515 under fence #1A1A1A in dark, page #FCFCFB under fence #FDFDFD in light — 2.5 and 0.4 L* of step, the rest done by the hairline). The precedents that look like wells are mirrors rather than recessions: a fence drawn darker in light and lighter in dark is a step taken in whichever direction the scheme had room for, and a text well drawn at the scale's extreme in each appearance is a convention about where content lives. If a fence receded while a card rose, one page would show two directions from one ground and THE CHECK below would stop being decidable by looking. - WHAT IS CHOSEN IS TINTED; WHAT IS TRANSIENT IS A NEUTRAL WALK. The item the window is currently showing — the open note, the open conversation — fills from c.Ramps.Primary.Step(300). Hover, pressed and the keyboard cursor stay neutral step walks over the region's own ground. A list has to show a cursor and a current item at once without the two colliding: a neutral fill says something happened here, a tinted one says this is the one you are looking at. - THE TITLEBAR WEARS THE GROUND OF THE REGION IT CAPS. Never leave an unpainted native strip above a painted window. Where the platform allows it, take desktop.FullSizeContent() and let the capped region's own fill reach the window's top edge; where it does not, paint your own band in that region's fill. For a plain page the whole cap is one call: desktop.CapTop(height, w) pads the page down past the strip AND claims that strip for dragging, so the two halves cannot be written apart. desktop.InsetTop(height, w) does the padding alone, for a layout that places its own drag region because it has band furniture to work around. The strip is part of the region below it, not a fourth kind of area. - TAKING THE STRIP TAKES ON WHAT CAME WITH IT, and a window dressed to the fill alone gets both halves wrong. The platform's window controls now stand inside your layout, so the region reaching the top-leading corner owes them their run, reserved from a measurement and never from a guess: desktop.ButtonRunIn(band) centres the group in a band of that height, desktop.ButtonRunAt(inset) reads the same rule from a leading inset you have already chosen, and desktop.BandLead(gap, gutter) — BandLeadFrom over a measurement you already hold — says where the band's own content may start (desktop.WindowButtonDiameter and WindowButtonPitch are there for the arithmetic in between). And the native drag leaves with the native strip: a window that claims nothing back cannot be moved by its top edge at all, so the capping regions claim it over their own empty runs — desktop.DragTop(gtx, height) over the strip while leaving the button run alone, desktop.DragRun(gtx, w) over a run of that width inside a row already being laid out, desktop.DragBand(gtx, r) over a rectangle you have measured. A page with no band furniture needs none of the three: CapTop above claims what it insets. - ONE BAND HEIGHT ACROSS A SPLIT. Where the strip crosses a seam — furniture on one side of it, content on the other — the two sides wear their own fills but hold one height between them. That height is shell.NavbarHeight(density): ask for it rather than restating a number, and the two sides cannot drift. Two depths across one strip read as a step in the window's top edge, which is the edge a reader measures every other alignment against. The seam itself paints a hairline, band included, and grabs a band several times that width; patterns/shell's SplitPane already does both, so an app on the shell restates neither. - THE CHECK, IN ONE SENTENCE: WALKING TOWARD THE VIEWER NEVER GETS DARKER, IN EITHER SCHEME. Floor, paper, raised, floating — sample a pixel from each and the lightness must climb. There is no mirror clause, because the check does not care which scheme is on, and no dismiss-the-overlays clause either, because it is taken along the depth axis rather than across the window's plane: a dialog is nearer than the paper AND lighter than it, so a modal satisfies the check instead of breaking it. The corollary is the same sentence in both schemes — A WINDOW'S FURNITURE IS ITS DARKEST REGION AND THE NEAREST SURFACE ITS LIGHTEST. A window darker in its middle than at its edges has the grammar inverted somewhere. None of this needs a token that does not already exist. A window that cannot be dressed from the vocabulary above is a window whose anatomy is wrong, not a token set that is short. workbench/vaultview is the window the grammar was read off: frame.go fills the whole window with Background and fills the sidebar and the aside from the chrome level, main.go takes the full-size-content treatment and insets what it must, frame.go places the window buttons with desktop.ButtonRunAt and starts the band's content at desktop.BandLead, and tree.go tints the current note with Ramps.Primary.Step(300) while the keyboard cursor stays a neutral walk. Why the grammar is this shape rather than another is in the design repository's rationale document: https://raw.githubusercontent.com/vibrantgio/design/master/DESIGN.md ## Motion tokens.MotionScale is the animation vocabulary, on the theme as the Motion observable. MD3's motion semantics at desktop pace: - DURATION STOPS, fastest to slowest: DurXFast 50 ms (hover feedback), DurFast 150 ms (small transitions), DurNormal 250 ms (standard enter/exit), DurSlow 400 ms (emphasized/large, fades), DurXSlow 500 ms (the ceiling; also the tooltip delay). Take durations from these stops, never from local constants — effects/motion's frame counts, toast's fade and tooltip's delay all resolve from the theme already. - EASINGS: the MD3 standard and emphasized families, each with accelerate (exit) and decelerate (enter) variants, as cubic-bezier control points (tokens.Bezier). - SPRINGS, for the effects physics path: SpringDefault (critically damped, brisk), SpringSnappy (slight overshoot — button "pop"), SpringGentle (soft). Use a preset or set mass/stiffness/damping together, never one field alone. ### Reduced motion While the OS preference is on, the theme's Motion emits MotionScale.Reduced() — every duration zero, easings and springs unchanged. A duration-driven component therefore completes in zero frames and snaps; a spring-driven component must read the zero durations as the snap signal and jump to its target (no finite spring settles in one frame). Derive your animation lengths from the scale and this behaviour is free. The raw accessibility streams live in theme/a11y (A11yPrefs: ReduceMotion, HighContrast, IncreaseTextSize — polled observables via a11y.Live). Prefer the composed theme — Motion and Color already reflect the first two; reach for the raw stream only for something the theme does not encode, such as IncreaseTextSize. ## Blur (effects/blur) Gio has no blur primitive; effects/blur owns one — a parallel three-pass box approximation of a Gaussian, plus the two supported ways to ship it: ### Static imagery: blur.Cache A known source image blurred once and reused — a hero image behind text, a frosted thumbnail: ```go var cache blur.Cache // long-lived, e.g. per layer op := cache.Image(src, sigma) // paint.ImageOp, cached on // source identity+sigma+size ``` Repeat calls with unchanged inputs do no work. Large radii render at a reduced size and upscale on the GPU (the divisor defaults from sigma). ### Backdrops: blur.Backdrop The "blurred behind the dialog" pipeline. It renders a caller-supplied layer into an offscreen headless GPU window at reduced resolution, reads back, blurs, and serves a paint.ImageOp. Two contracts to honour: 1. REFRESH POLICY. Update runs the whole pipeline synchronously on the calling goroutine (milliseconds — for a 1440×900 backdrop, ~3 ms at the default divisor and 29 ms at full resolution, against a 16.7 ms frame budget). Call Update only when the content behind the blur actually changed — a scroll settled, a dialog opened — and paint the cached Op() every frame in between; Op is free. 2. FALLBACK. Headless GPU rendering is not available everywhere. Update returns the error and Op reports ok == false; paint a flat tinted scrim instead — blur.FallbackOp(tint) is ready-made, and blur.Available() answers up front. Never assume the blur. ### What not to blur Animated glows. This was prototyped, measured and rejected (the evidence is in effects/glow's package doc): an animating blur-glow costs 0.2–0.8 ms of events-thread CPU plus an allocation and texture upload per glow per frame, against ~0.5 µs for glow's eight-gradient halo, and no cache holds while the radius or intensity animates. Use effects/glow for halos; a correct approximation beats a slow exact answer. ## Typography THE THEME OWNS THE TYPEFACE. Every component that draws text takes its type style and its *text.Shaper from the theme observable it already receives — application code does nothing to get correct type: ```go import "github.com/vibrantgio/components/button" // th is the theme/theme.Theme the app passes to every component. btn := button.Button(th, button.Props{Label: "Save"}) // renders Roboto ``` The contract lives in theme/tokens.Typography: one tokens.TextStyle per Material Design 3 type role — Display, Headline, Title, Label and Body, each Large/Medium/Small, fifteen roles in all — plus Code, a sixteenth style outside the MD3 grid: BodyMedium's metrics on the mono face, the style code renders in (MD3 has no code role, so the org added one) — plus DocumentHeadings, a tokens.DocumentHeadingScale of six TextStyle stops for prose surfaces, stepped off BodyLarge (1.6× body down to 0.875×, bold throughout) because the Display and Headline roles size a screen's one headline, not a document whose headings recur every few paragraphs; address it by heading level with scale.Level(n). A TextStyle carries Typeface, Weight (CSS-style numeric: 400 regular, 500 medium, 700 bold; tokens.FontWeight converts to Gio's font.Weight), and Size, LineHeight and Tracking in dp. Typography also carries Faces, the font collection, and Shaper(), which builds ONE text.Shaper from Faces on first call — with the platform's own fonts behind them as fallback — and caches it in the value; it is safe for concurrent use and shared by every component. The fallback is deliberate: Roboto and Roboto Mono carry no arrow, no box-drawing character and no dingbat, so a shaper confined to them draws tofu for text a real application genuinely receives. theme/theme.Theme.Typography is an rx.Observable[tokens.Typography]; theme.Default() and theme.AutoLightDark emit tokens.DefaultTypography, whose fifteen roles name "Roboto", whose Code names "Roboto Mono", and whose Faces are vibrantgio/font's Roboto faces plus the Roboto Mono faces Code resolves against. theme/system's LiveTheme emits tokens.EmojiTypography() — DefaultTypography.WithEmoji() — so the live stream wears Noto Color Emoji as fallback. Gio's system fonts do not supply a color-emoji face; without that append, 😀 is Roboto's .notdef. Goldens and DeterministicShaper stay on DefaultTypography so they do not parse the 9.9 MB face. Roboto is the default because the DEFAULT TYPOGRAPHY names it — restyle an app by putting a different Typography on the theme, never by wiring fonts per component. ### Line height needs theme/typeset TextStyle.LineHeight is the height of the line box, the CSS meaning, and handing it to gioui.org/widget.Label does NOT produce that. Gio baselines the first line at its own ascent and spends the line height only on the gap to the next, and widget.Label reports glyph ink as its size — so a MaxLines:1 label, which nearly every control is, measures the same at any line height at all, and wrapped text lands one deficit short of a whole multiple. Draw text through theme/typeset instead: typeset.Font(style, fallback) builds the font.Font, typeset.Label(style, maxLines) the widget.Label with the line height installed, and typeset.Layout(gtx, shaper, lbl, f, size, txt, material) lays it out and pads the result to the line box. That is also why a control's drawn height is max(Density.ControlHeight, lineBox + 2*PaddingY): ControlHeight is a FLOOR, not a height, and a Comfortable text field (BodyLarge, 24 dp line box) draws 40 dp against that 36 dp floor while a Comfortable button (LabelLarge, 20 dp) draws exactly 36. ### Text you draw yourself with textdraw It follows the same source: subscribe th.Typography, take metrics from a role and the shaper from typ.Shaper(). The positional-shaper APIs — markdown's doc.Layout(gtx, shaper, style), components/richtext's Layout and Render, patterns/shell's golden Render/RenderThreeColumn/RenderStackedPage — want that same typ.Shaper() value. There is no second, size-only type source any more: theme.Theme.Type and tokens.TypeScale are deleted, and markdown.FromTokens takes the whole Typography like every other multi-role consumer. The Typography roles carry the full metrics and are the only source of truth (see §Modules). ### The rules - Never construct a shaper. Typography.Shaper() is the only shaper an app needs. The style module (style.FontFaces() and the H1-H6 scale) is frozen; do not add it to a new app. - Never gofont. nil Props.Shaper means the theme's shaper, which is correct. - Props.Shaper is a DELIBERATE override only. The prop survives on the text-drawing components for the rare call site that must shape with a different collection; leave it nil everywhere else. If you find yourself passing it routinely, you are reimplementing the old defect. - A GOLDEN TEST PINS ITS FACES; APPLICATION CODE DOES NOT. Typography has two shaper constructors and they are not interchangeable. Shaper() is the application one: Faces first, then the platform's own fonts, so all text resolves — including the glyphs no embedded face was ever going to carry. DeterministicShaper() is the test one: Faces and nothing else, system fonts off, so the same text shapes to the same pixels on every machine. If you write a golden in your app, use the second. A golden written against Shaper() passes on the machine that wrote it and fails on one with a different font set. ### When a test needs a glyph the default faces lack Widen the collection rather than reach for the system. WithFaces returns a copy of the Typography with extra faces appended last and both shaper caches cleared, so the default family still wins for text naming no typeface: ```go typ := tokens.DefaultTypography.WithFaces(notosansmono.FontFace()) shaper := typ.DeterministicShaper() ``` font/notosansmono is the org's optional symbol face — box drawing, blocks, geometric shapes, arrows, maths operators — deliberately absent from DefaultTypography.Faces because Shaper()'s system fallback already covers what it carries. Add it where there is no system to fall back on: a container, a kiosk, anything shipping its own world. And do not then put the symbol in a golden image: the face serving it is exactly the machine-dependent thing goldens exist to avoid, so assert that the shaper resolved the rune to a real face and keep it out of the pixels. ### The live stream wears emoji; goldens pin the default font/notocoloremoji is the optional color-emoji face. The same DefaultTypography.Faces rule applies — do not add 9.9 MB to every golden — but the system fallback does NOT cover emoji, so a live document that draws 😀 would tofu without it. That is why the live stream wears it: Typography.WithEmoji appends the one face (and returns the receiver when it is already there); EmojiTypography() is DefaultTypography.WithEmoji(), built once. LiveTheme defaults to that value; Brand.Typography() and Brand.Options() apply WithEmoji on top of the code face, including when Mono is empty. First-frame snapshots must use the same value or the first emoji flashes tofu. Pinned Noto Color Emoji goldens use DefaultTypography.WithEmoji().DeterministicShaper(); default-path goldens stay emoji-free. The workbench apps embody all of this: none of them builds a shaper, imports style or gofont, or passes Props.Shaper — copy any of them. Code defaults to Roboto Mono. A kept "mono": "JetBrains Mono" in theme.json restyles Typography.Code and appends those four faces; empty, absent, or unknown stays on Roboto Mono. The live stream still wears emoji on top of whichever code face is kept. Goldens and DeterministicShaper pin the default. ## Markdown documents github.com/vibrantgio/markdown renders GitHub-flavoured markdown as components widgets — use it for docs pages, help screens, and chat message bodies instead of hand-coding text layouts: ```go blocks := markdown.Parse(src) // goldmark+GFM AST → block model doc := markdown.NewDocument(blocks) // allocate ONCE; holds scroll, // link, and image state across frames style := markdown.FromTokens(colors, typ) // typ is tokens.Typography style.Text.OnLinkClick = func(gtx layout.Context, url string) { ... } style.OnTaskClick = func(gtx layout.Context, item *markdown.ListItem) { ... } // opt-in; nil stays display-only doc.Layout(gtx, typ.Shaper(), style) // scrolling viewport, O(visible); // typ is the theme's Typography ``` doc.LayoutColumn(...) lays out at natural height with no internal scroll — use it inside an outer scrolling context (a chat row, a card); Layout's own viewport would fight the outer one. - Constructs: headings set in the Typography's DocumentHeadings ladder rather than in the screen roles (§Typography), richtext paragraphs, nested lists, task-list checkboxes, strikethrough, blockquote bars, rules, and GFM tables (columns shrink within their slack, floored at the widest word; a table that cannot fit scrolls horizontally). - Task-list checkboxes are display-only until OnTaskClick is set, the same opt-in as Text.OnLinkClick. The hook receives the *ListItem Parse produced (same pointer; MarkerOffset is the opening '[' in that source). The library does not write files. - Checkbox colour splits across two fields because the two states draw on different grounds: Style.CheckboxBorder strokes the open box against the page and is gated to GraphicFloor like any other ink (§Colour); Style.CheckboxFill is the closed box's solid fill and takes the brand colour ungated, because it is Style.CheckmarkColor's tick that reads against it, not the page. - Code renders in the theme's mono face: FromTokens resolves Style.Mono and CodeSize from the Typography Code style (Roboto Mono by default; JetBrains Mono when the kept "mono" names it), on a tinted neutral-300 surface with horizontal overflow scroll. Syntax colours are opt-in: style.Highlight = highlight.Adapt(highlight.DefaultBase, colors) (markdown/highlight, chroma) — one base name for both appearances, which member of the pair is derived from follows the tokens. Adapt DERIVES a style rather than wearing one: each entry keeps its hue and chroma and takes the lightness that clears WCAG AA against the fill your theme puts under a fence, with one bold/italic policy across the pair and the plain runs still falling back to Style.CodeColor. DefaultBase is catppuccin-latte (counterpart catppuccin-mocha); pass any name chroma's registry holds. Derive once per theme, not once per frame. highlight.New(name) still wears a stock style verbatim — stock styles are never mutated, and a style fitted to a near-white page will measure short on the neutral-300 fill (github's keyword red reads 3.61:1 there). If you supply a custom Typography, set Style.Mono and CodeSize from its Code role after FromTokens. - Images: paragraph-sole ![alt](url) blocks only; the library performs NO I/O. Set style.Images to an ImageProvider (raster pixels) and/or a WidgetImageProvider (vector widgets); markdown/svgimage serves .svg destinations from a caller fs.FS (go:embed assets) as crisp vector widgets via vibrantgio/svg. Unknown destinations fall back to italic alt text — mindchat uses exactly this to bundle provider icons (assets/openai.svg) while remote URLs stay network-free. - Working consumers: sitedocs (renders this guide whole as one document, with an outline tree scrolling it via ScrollToBlock — the docs-app recipe, including the light/dark highlight switch), mindchat (message bodies degrade() to the chat subset: inline styles, fences, images and lists pass through — a subset keeps the constructs the document already draws well and flattens only what would grow document chrome in a bubble, so headings, blockquotes and tables become paragraphs while a list stays a list, markers and all). ### Obsidian-flavoured notes They need markdown/obsidian, the recognition half of that dialect. It works on the source and on the public block model, never inside the parser, and it adds no dependency: ```go fm, body := obsidian.SplitFrontMatter(src) // properties off the top blocks, anchors := obsidian.BlockAnchors( // "^id" tails → indices obsidian.WikiSpans(markdown.Parse(body))) // [[link]] → link spans doc := markdown.NewDocumentAt(blocks, anchors["intro"]) ``` - Split the frontmatter BEFORE Parse. A leading "---" block otherwise renders as a thematic rule with the key lines above the closing "---" turned into a setext heading — visible garbage at the top of the note. The fields are read by a trivial line split (scalars and "- item" lists) and FrontMatter.Raw keeps the text, so a document needing real YAML hands the raw block to a parser of its own choosing. - WikiSpans lifts [[target]], [[target|alias]] and the ![[target]] embed form into ordinary link spans: Text is the alias when written, URL is "wiki:" (or "wikiembed:") plus the raw link body. Spans already marked Code or already carrying a URL are never split — code is not a link edge. NOTHING IS RESOLVED: what a target names needs the folder the notes live in, which this library never reads, so the application decides in its own Style.Text.OnLinkClick. - BlockAnchors strips trailing " ^id" tails from what is displayed and returns id → top-level block index — exactly what NewDocumentAt takes, so a link into a note lands where the anchor is. - One limitation, by construction and pinned by a test: a span is a styling run, so a wikilink whose body crosses a styling boundary ("[[a *b*]]") is not recognised. Link targets carry no styling. ### components/richtext The underlying inline primitive — styled spans with interactive links (hover cursor, keyboard traversal, focus ring, OnLinkClick(gtx, url)) — usable directly when you need styled text without a goldmark document around it. ## Icons The Vibrant Gio iconset is components/icons (github.com/vibrantgio/components/icons): the marks the standard controls need, drawn on one grid at one weight. It carries four — sidebar, disclosure, history-back, history-forward. A name says what the control does, never what the picture contains, because the drawing behind a name differs between platforms and does change, while the name is the part call sites store. ```go import "github.com/vibrantgio/components/icons" if mark := icons.Mark(icons.Sidebar); mark != nil { mark(gtx, gtx.Dp(20), c.Text) // colour from the theme's tokens } ``` Mark returns a Painter — func(gtx layout.Context, sizePx int, col color.NRGBA) — which is exactly the shape components/button Props.Icon takes, so a mark drops straight into a control's icon slot. It draws into a square of sizePx at the current origin, centred, and takes its colour at paint time, so a control animating its foreground costs no rebuild. Mark returns nil for a name the set does not carry, which is also what an icon slot reads as "no icon" — a missing mark degrades rather than panics. Has asks the same question without building a painter; Names lists the set. One name, one drawing per platform: a lookup takes the host system's own drawing (marks/..svg) where the set carries one and the shared drawing (marks/.svg) where it does not, so macOS gets its own sidebar and every other platform gets the fallback rather than someone else's idiom. Nothing is selected by build tag — the whole set is compiled in and the choice happens at run time, so icons.New(goos) asks for another platform's answer on this one, and Set.Resolve reports which file answered. Register fills a components/icon Registry an application owns with its own parsed copies. ### Adding a mark Adding a mark is adding an SVG file under components/icons/marks — the file name is the whole registration mechanism. The package documentation is the authority on how: the 24x24 grid and its keyline, the two stroke measures that read as one weight (1.5 units axis-aligned, 2 diagonal), outlines rather than strokes because Gio's clip.Stroke exposes neither cap nor join, and non-zero winding for holes. Read it before drawing — every number there is a measurement, not a preference. ### The Material catalogue For a glyph outside the standard controls (a settings button, play/record controls, a close X), use the Material Design icons published as IconVG data in golang.org/x/exp/shiny/materialdesign/icons (961 icons, frozen 2016 Material set) and render them with ivg/raster/gio: ```go import ( "golang.org/x/exp/shiny/materialdesign/icons" raster "github.com/vibrantgio/ivg/raster/gio" ) w, err := raster.Widget(icons.ActionSettings, 40, 40, raster.WithColors(p.Icon)) // w is a layout.Widget; wrap it in a widget.Clickable to make a button. // See todos/view.go (the FAB) and todos/list.go (the delete icon). ``` Names are Category+Name: ActionSettings, AVPlayArrow, AVStop, AVPause, AVFiberManualRecord, ContentAddCircle, ContentClear, NavigationClose, ... Colour the glyph from the theme's tokens — the pinned Primary for accent glyphs, a neutral text step otherwise — never a hard-coded colour, and that holds for a mark as much as for a Material glyph. Run `go run ./iconbrowser/` (in the workbench repo) to see both sets: the marks at the sizes controls draw them, over the searchable Material grid. ### Arbitrary SVG assets Brand marks and logos render via vibrantgio/svg: svg/driver/gio's IconWidget(icon, w, h, opacity) returns a vector layout.Widget from a parsed *svg.Icon (parser.NewParser(...).ParseStream); inside markdown documents use markdown/svgimage instead (see above, and mindchat/assets.go for the embedded-asset pattern). ### Reach for the set first Reach for the set first where it carries the mark, and use the Material catalogue freely everywhere else: an icon that may be replaced later beats no icon, and a Material glyph swaps out behind the same widget when the set grows a mark for it. Do not plumb shiny icons into components/button Props.Icon — that field wants the painter func(gtx, sizePx, col) the set returns, not a widget; keep IVG icons in your own clickable until an adapter exists. Patterns draws its built-in chevrons/X with hand-coded clip paths on purpose (golden determinism); leave those alone. ## Pitfalls in an application (each caused a real bug in the example apps) - A Defer factory reruns per subscription: sharing an observable that owns state across two subscribers duplicates the state. Share/Publish upstream. - The first emission fires before the first frame; layout-derived values are unknown. Use components/initial (initial.Value[T], whose zero value is unset; v.GetOrSet(fn) computes it on the first frame) instead of ad-hoc sentinels. - Re-subscription is not supported by Render: build new layers for a new Render call; do not revive a completed subscription. - Component prop values are captured at construction unless they come from the model observable — pagination Page/PageCount, table sort state, etc. must flow from the Model, not be captured as static ints. - Radius tokens: tokens.Radius.Full is a 9999 dp pill sentinel and Gio's clip.RRect does not clamp — pass it raw and paint sprays across the canvas. Use components/layout.Pill, which clamps to min(w,h)/2. - theme/system.LiveTheme polls the OS. The reference apps pass one second; the appearance stream is cold today — each subscriber runs its own poll loop — so wire LiveTheme once per window and let the theme observable fan out, rather than calling it per layer. - gio input areas OCCLUDE pointer events by default. A window-wide event.Op area registered only for KEY events (a global shortcut) must be wrapped in pointer.PassOp and laid out first, under the content — otherwise every click in the app dies and the UI looks frozen (this locked up mindchat at startup). - seen is a multi-module repo: require both github.com/vibrantgio/seen and github.com/vibrantgio/seen/context/gio at the same tag when using the Gio context. ## Minimal go.mod for a new app ``` module example.com/myapp go 1.25.1 require ( gioui.org v0.10.2 github.com/reactivego/rx v0.3.0 github.com/vibrantgio/patterns v1.2.1 github.com/vibrantgio/mvu v1.0.1 github.com/vibrantgio/components v1.3.3 github.com/vibrantgio/theme v1.3.3 github.com/vibrantgio/textdraw v0.0.5 ) ``` These are the released tags — the same ones §Modules lists, and the same ones all eight workbench apps are pinned to. Typography and colour need no module of their own: both travel on the theme, which comes with theme; do NOT add the frozen style module to a new app. textdraw is a direct requirement as soon as you draw text yourself with textdraw.Text/Label — todos does. github.com/vibrantgio/font arrives INDIRECTLY, as theme's dependency; `go mod tidy` marks it `// indirect` and only promotes it if you import a face package yourself. The faces are hosted in that module — do not require eliasnaur.com/font. Add markdown/effects/backdrop/seen/traer as needed. All repos are public on GitHub. ## Where to look for working code All of these are directories in github.com/vibrantgio/workbench. Every one of the nine apps embodies the current contract — theme imports (no alias paths), ramp/pin colours (no MD3 aliases), theme typography (no app-built shaper), theme-driven component entry points — so copy any of them without the caveats older guides carried: ### todos/ START HERE: the minimal complete app (~700 lines) — the canonical bootstrap, pure testable reducers (redux_test.go), components button/checkbox/list, a modal dialog, live light/dark; theme.go is the minimal token-adapter recipe (PaletteFrom resolving the window anatomy above in one function — the list is the content ground so it wears level 0 and paints no surface at all, the dialog over it takes level 2 and the field inside the dialog level 3 — plus TypeFrom picking Typography roles), and window_render_test.go is the smallest example of asserting those rungs off a headless frame instead of off the palette that meant them ### iconbrowser/ Both icon sets in one window: components/icons at the sizes controls draw them, over the searchable Material grid — components TextField with per-keystroke MessageOp updates, subscription-scoped scroll and editor state, ivg icon rendering at scale ### mindchat/ The most feature-complete app: a multi-provider LLM chat client (any OpenAI-compatible Responses endpoint; providers configured with their own BaseURL/key) whose side effects all run as mvu commands — a completion STREAMS dozens of messages back through the loop, routed by stream id so switching/renaming/deleting chats mid-stream stays safe. Message bodies render as markdown (chroma-highlighted fences with code in the mono face, links opening the OS browser, bundled SVG provider icons; markdown.go's degrade() + docCache are the chat-consumer recipe). Its window is a FLOATING PANE composition (frame.go): the conversation list floats inset from the window's leading, top and bottom edges and takes no width at all when dismissed, with the chrome row beside it carrying the conversation's title and the model picker — and, once the pane is away, the same two controls the pane's own strip carried, at the same height and figure. Also: trash-backed session-wide undo (Cmd/Ctrl-Z), Cmd/Ctrl-N, Cmd/Ctrl-, and Cmd/Ctrl-\ accelerators, the modal-form recipe (Seed/FocusTag/DynamicFocusTags), list scrollbars, and a global-shortcut key area done right. Chatting needs OPENAI_API_KEY in the environment (or .env) on first run to seed the config ### The front door (root) The workbench front door, and the repository's own root package rather than a directory beside the others, so that `go run github.com/vibrantgio/workbench@latest` opens it — a seen 3D triangle field composited as an mvu background layer (palette re-keyed per theme emission via an atomic handoff to the animation tick), hero + patterns cards over it, and one streaming mvu.Command per launched app (Started, then Exited when the process ends). A card runs the app's own latest release; a launcher built from the checkout runs the copy next door instead ### feeds/ Table-heavy app: sort/filter/pagination, tabs, split pane, modal forms, toasts; app.go's mirrorTokens is the reference theme-to-frame-time adapter (§Layers), and wiring_test.go shows how to test the AutoConnect count. Also the reference implementation of the dialog grammar's invocation half: shortcut.go binds ⌘,/Ctrl-, and preferences.go is the settings PANEL it opens, whose two preferences apply live under it ### themer/ Pick a brand colour out of a picture: an image dropped anywhere on the window comes back as a row of seed candidates — clustered and ranked by theme/imageseed — each swatch beside the primary pair FromSeed makes of it, and clicking one re-themes the window. The reference for OS file drops (mvu/desktop's ZoneGroup + DropTarget merged into the loop as messages, a window-wide zone, hover highlight) and for a theme observable the APPLICATION re-seeds: the OS still decides light or dark, the app decides the colour. A path argument takes the same path a drop does. Keep writes the chosen seed through theme/brand, which is where the other apps read it from. The page under the row is four tabs over patterns/tabs, drawn in the palette being judged rather than the window's: Theme — this seed's ramps and named picks with where each colour came from, and the type ladder — then Components, Patterns and Markdown, one per group of the components/gallery/inventory catalogue, with the syntax-base list riding in the code specimen's own row on the last of them. The tab and its scroll position survive a pick, and the catalogue is parsed once for all four ### sitedocs/ The documentation app, five tabs over patterns/tabs: Docs — this guide (llms.txt) rendered whole by vibrantgio/markdown, its ##/### outline as a tree whose rows scroll the document (ScrollToBlock); Theme — the palette story, opening with the colour the palette grew from and going on to the ramps grid, the named picks and the type ladder, following the live theme; then Components, Patterns and Markdown, one per group of the components/gallery/inventory catalogue, as live controls. A tab showing a single group drops that group's banner, and the inventory's own colour sections are dropped for the Theme tab's telling — the inventory module is untouched, the app chooses which of its sections to lay out. The reference for a tabbed shell and for an outline tree over one long document ### marketing/ A fictional SimpleApps landing on an outline field: one full-screen page, macOS full-size-content chrome so the traffic lights sit on the surface ### vaultview/ The document reader: a viewer for a folder of markdown notes written in the Obsidian style. The genre no other app covers — open-ended user content rather than an owned data model — so it is where the navigation vocabulary lives: a disclosure file tree over components/list with a name-filter field, a back/forward history stack, breadcrumbs, and the patterns/shell aside slot finally in use as a backlinks panel. Notes render through vibrantgio/markdown with markdown/obsidian around it (frontmatter split off, wikilink spans, block anchors); resolving what a wikilink names is app-local and pure — one index built by an mvu.Do scan off the render goroutine, pure functions over it, every rule pinned by a test. Read doc.go first: it states the link contract the tests enforce. The one write is a GFM task marker: OnTaskClick splices `[ ]` ↔ `[x]` in the file before the handler returns, and nothing else is writable. Also the reference for adopting a kept brand: one theme.LiveTheme option, and the same value seeding mirrorTokens' first cell. And the window the surface-rung grammar was read off: a Background ground running to the top edge under the full-size-content treatment, panes raised one rung to Surface, the current note a Primary tint against a neutral cursor. DESIGN.md lives in github.com/vibrantgio/design if you want the architecture rationale. You do not need it to write an app.