# 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 org's `.github` repository and exists exactly once; every repo's AGENTS.md links this raw URL rather than copying the content, so this is the version to read and the version to edit: https://raw.githubusercontent.com/vibrantgio/.github/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/workbench; the apps in that repo (todos/, sitedocs/, feeds/, watchlist/, mindchat/, iconbrowser/, launcher/) 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). Both rules are enforced by lints that fail `go test`, and therefore CI, in the four component repos. ## Modules and versions All modules live under github.com/vibrantgio/, layered per ADR-001's tier table: a module imports only modules in strictly lower tiers, plus the support row — enforced in CI by the org's check-layers script. Current tags: tier 0 — leaves mvu v0.4.4 MVU runtime: NewWindow, Loop/Run, Message/Command, MessageOp font v0.1.0 Roboto, six weights, regular and italic — plus Roboto Mono, normal and bold each with italics, all OFL; the default typography's faces. 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 textdraw v0.0.4 low-level glyph text drawing backdrop v0.0.3 solid colour fill widget gradient v0.0.3 linear gradient fill widget circle v0.0.4 precise circle via Bezier style v0.0.6 FROZEN (ADR-003): the old MD2 type scale and FontFaces(); superseded by spectrum/tokens Typography (§Typography) — never add it to a new app tier 1 — the foundation spectrum v0.4.0 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 tier 2 prism v0.3.1 components: button, input, list, richtext, scrollbar, icon, layout, keyed, initial, cache, coordination, bench tier 3 pulse v0.1.2 effects: blur (Gaussian kernel, cached blur, backdrop pipeline), depth, glow, motion, spring, springbutton, transition, tween, conductor tier 4 cadence v0.3.1 patterns: shell, navbar, sidebar, table, pagination, tabs, modal, alert, popover, tooltip, toast, card, accordion, breadcrumb, hero, feature, pricing, testimonial markdown v0.1.2 GFM document rendering on prism widgets (subpackages: highlight — chroma syntax colours; svgimage — SVG images via vibrantgio/svg) support libraries — consumed by the design system, never depend on it seen v0.0.7 3D scene graph rendered to SVG or Gio traer v0.0.8 particle physics (springs, attractions, Verlet) svg v0.0.9 SVG parse + render ivg v0.1.6 IconVG icons, Material icons bundled noise v0.0.3 Perlin and Simplex noise, 2D/3D kiwi v0.0.6 Cassowary constraint solver csg v0.0.1 constructive solid geometry on meshes (BSP trees) EVERY TAG ABOVE IS RELEASED AND CURRENT. The numbers are what the release cut, not what was planned: spectrum went v0.1.0 → v0.2.0 → v0.3.0 → v0.4.0, prism to v0.3.1, pulse to v0.1.2, cadence to v0.3.1, markdown to v0.1.2, mvu to v0.4.4, font to v0.1.0 and svg to v0.0.9. The v0.0.10+ tags some of these repos carry are buried and never reused — they violated the org's no-double-digit rule (§Versioning). style stays frozen at v0.0.6. 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 spectrum/... path they used to forward to) and spectrum/transition (import pulse/transition — that is where the colour-token tween lives, and it is why spectrum no longer depends on pulse 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 ladder tops out at 3, and asking for a higher level now panics rather than clamping; - tokens.TypeScale entirely. The static `Render(…)` signatures on prism, cadence 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. ## Nested modules Ten 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 v0.1.6` is `raster/gio/v0.1.6` in the ivg repo, not `v0.1.6`. github.com/vibrantgio/ivg/raster/gio v0.1.6 Gio rasterizer + Widget() for IconVG github.com/vibrantgio/svg/driver/gio v0.0.9 SVG -> Gio ops, IconWidget() github.com/vibrantgio/svg/driver/raster v0.0.9 SVG -> raster image github.com/vibrantgio/svg/driver/pdf v0.0.7 SVG -> PDF github.com/vibrantgio/svg/driver/seen v0.0.8 SVG -> seen scene github.com/vibrantgio/seen/context/gio v0.0.7 Gio drawing context and widget for seen github.com/vibrantgio/traer/gio v0.0.8 Gio examples/render for traer physics github.com/vibrantgio/kiwi/gio v0.0.6 Gio layout on the kiwi solver github.com/vibrantgio/prism/gallery v0.3.0 the prism component gallery app github.com/vibrantgio/mvu/example v0.4.4 numbered mvu examples, 00-reference upward A nested module's version mirrors its root's: `driver/raster/v0.0.9` requires `svg v0.0.9`. A root can be ahead of a nested module that did not change — `svg v0.0.9` with `driver/pdf v0.0.7` is correct, not drift. When a root and its nested module are both used, require them at matching versions: `seen` and `seen/context/gio` are the case you will hit first. External pins: gioui.org v0.10.1, 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; watchlist/main.go the production-sized one): package main import ( "time" "gioui.org/app" "gioui.org/unit" "github.com/reactivego/rx" "github.com/vibrantgio/mvu" specsystem "github.com/vibrantgio/spectrum/system" specwin "github.com/vibrantgio/spectrum/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)), ) // spectrum/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 := specwin.New(mvuWin, specsystem.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: watchlist keeps its disk writes in submit CALLBACKS by design (the write stays synchronous with the confirming click) while its reducers return DoNothing — both placements compose with mvu.Loop. ## 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: 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 pass Defer-scoped state to a goroutine. For per-row state in dynamic lists use prism/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: mvu.MessageOp{Message: SelectItem{ID: id}}.Add(gtx.Ops) The runtime extracts these from the ops buffer and delivers them to Update. Cadence/prism component callbacks receive gtx for exactly this purpose. This is the bridge between components and MVU; FRP-style apps may instead pass plain OnClick callbacks and wrap them in subjects. Modal forms: prism 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 cadence/modal's DynamicFocusTags puts it in the Tab cycle (first tag gets initial focus on open). cadence/modal sizes its surface to the content, and Props.HideClose drops the corner close button when footer Actions carry an explicit Cancel (see mindchat's rename modal for the full recipe). 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 watchlist/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: 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 (prism/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). ## 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 cadence/shell: - cadence/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). - cadence/navbar + cadence/sidebar fill the shell's slots. - Content composes cadence/table (sortable/filterable/virtualised), cadence/tabs, cadence/card, cadence/pagination, ... - Overlays: cadence/modal (focus trap, escape), cadence/popover (anchored), cadence/tooltip (hover/focus), cadence/toast (transient stack), cadence/alert (inline banners). 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. spectrum/tokens supplies the typed design values (ADR-007 colour ramps, a 4-pt spacing scale, named radius stops); never use string-keyed style maps. prism/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). Pulse components are explicit VARIANTS of prism components, e.g. pulse/springbutton wraps prism/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 watchlist and sitedocs. ## Colour: one seed, ramps, pins, step walks COLOUR IS DERIVED, NOT PICKED (ADR-007, landed in Phase D). One seed colour generates the entire palette — both modes — through the CIELAB/OKLCh engine in spectrum/color: light, dark := tokens.FromSeed(seed) // paired ColorTokens; the light // Primary pin is the seed exactly Application code never writes a colour literal. The no-literal-colour lint fails `go test`, and therefore CI, on a color.NRGBA literal in prism, pulse, cadence and markdown (allow-listed exceptions only); write application code to the same standard. Every colour you need is on the tokens.ColorTokens value the theme's Color observable emits. The vocabulary (this replaces MD3's role tables — see the deleted-alias list in §Modules): - 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. - 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 FocusRing() (neutral 500). // c is the tokens.ColorTokens the theme's Color observable emitted. card := c.Surface // neutral 200 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 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 spectrum/system's LiveTheme — emit the default seed's palette. Options on LiveTheme/FromSourceTheme brand an app without giving up live OS tracking: // one brand colour; everything else derived, dark mode still live specsystem.LiveTheme(time.Second, specsystem.WithSeed(brand)) // full control: both schemes supplied, OS still picks which is live specsystem.LiveTheme(time.Second, specsystem.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. spectrum/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. ## 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 spectrum/tokens density.go. Prism and cadence components 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: th := rx.Map(specsystem.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 ladder A raised surface reads as raised by TINT first, shadow second (ADR-005 for desktop). Elevation is a walk up the neutral ramp — the same move states make — with the level naming the rung: level 0 the app background (the Background pin) level 1 card / raised-in-place surface — neutral 200 level 2 menus, toasts, higher panels — neutral 300 level 3 the top of the desktop ladder — neutral 400 surface := c.SurfaceAt(tokens.Level1) // resolves the fill per mode Because the ramps are paired, a raised surface lightens in dark mode and darkens in light mode with no second rule — dark-mode surface tint, the thing MD3's tonal elevation existed to encode, falls out for free. State walks compose on top with the level's step as the ground: hover on a level-1 surface is StateColor(RoleNeutral, 200, StateHover) — neutral 300 in both modes. Levels 4 and 5 are GONE — they used to clamp onto level 3; asking for one now panics. Desktop has no six-storey stack. Shadows survive as OPT-IN vibrancy via pulse/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. ## 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 — pulse/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 pulse 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 spectrum/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 (pulse/blur) Gio has no blur primitive; pulse/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: 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 pulse/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 pulse/glow for halos; a correct approximation beats a slow exact answer. ## Typography THE THEME OWNS THE TYPEFACE (ADR-003, landed in Phase C). 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: import "github.com/vibrantgio/prism/button" // th is the spectrum/theme.Theme the app passes to every component. btn := button.Button(th, button.Props{Label: "Save"}) // renders Roboto The contract lives in spectrum/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). A TextStyle carries Typeface, Weight (CSS-style numeric: 400 regular, 500 medium; 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. spectrum/theme.Theme.Typography is an rx.Observable[tokens.Typography]; theme.Default(), theme.AutoLightDark and spectrum/system's LiveTheme all 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. 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 spectrum/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 spectrum/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 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), prism/richtext's Layout and Render, cadence/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. text.NewShaper in application code is the pre-ADR-003 practice; Typography.Shaper() is the only shaper an app needs. The style module (style.FontFaces() and the H1-H6 scale) is the old way — frozen at v0.0.6; do not add it to a new app. - Never gofont — and the build enforces it. The no-gofont lint (internal/lint in prism, pulse, cadence and markdown) fails `go test`, and therefore CI, on any gioui.org/font/gofont import in those four repos; the old practice no longer merely looks wrong, it fails the build. 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. Every golden and pixel test in prism, pulse, cadence, markdown and the workbench apps calls the second. A golden written against Shaper() passes on the machine that wrote it and fails on one with a different font set — silently, and only for whoever runs it next. That failure is the whole reason the two exist. 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: 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 workbench apps embody all of this: as of Phase F1, no app builds a shaper, imports style or gofont, or passes Props.Shaper — copy any of them. One pre-release seam remains: the mono face and the Code style are newer than the latest published spectrum and font tags, so a build resolved from tags (rather than the org's development workspace) renders code in Roboto until the release tags land. The guide describes the system as it ships in the working tree; §Modules records the honest tag state. ## Markdown documents github.com/vibrantgio/markdown renders GitHub-flavoured markdown as prism widgets — use it for docs pages, help screens, and chat message bodies instead of hand-coding text layouts: 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) { ... } 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: type-scale headings, 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). - Code renders in the theme's mono face: FromTokens resolves Style.Mono and CodeSize from the Typography Code style (Roboto Mono in the default faces), on a tinted neutral-300 surface with horizontal overflow scroll. Syntax colours are opt-in: style.Highlight = highlight.New("github") / "github-dark" (markdown/highlight, chroma) — pick by background luma so themes match (sitedocs/docs.go shows the light/dark switch). 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 (docs pages are embedded .md files — docs_content.go is just go:embed + a route table), mindchat (message bodies degrade() to the chat subset: inline styles, fences, images pass through; other blocks flatten to paragraphs). prism/richtext is 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 There is no Vibrant Gio iconset yet. When an app needs a glyph (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: 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. Run `go run ./iconbrowser/` (in the workbench repo) to search the set visually. Arbitrary SVG assets (brand marks, 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). An icon that may be replaced later beats no icon: use these freely; a future vibrantgio-owned set would register into prism/icon (an empty name→icon Registry today) and swap in behind the same widgets. Do not plumb shiny icons into prism/button Props.Icon — that field wants a clip.Path painter func(gtx, sizePx, col), not a widget; keep IVG icons in your own clickable until an adapter exists. Cadence 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 prism/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 prism/layout.Pill, which clamps to min(w,h)/2. - spectrum/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. ## Pitfalls when working on the org's repos (each cost real time in B–E) - GREEN HAS TWO MEANINGS, and a change is done only when it has both. Development runs under the org's single go.work, resolving siblings from the working tree; CI and every stranger's `go get` run without it, resolving each go.mod against published tags. A cross-repo change can be green in the tree and broken for the world — that gap is permanent between a seam landing and its tags being cut. Verify both: build/test normally, then again under `GOWORK=off`; the org's check-no-workspace.sh script reports the outstanding debt. Never commit a `replace` directive or a go.work file into a member repo. - A STALE go.sum CAN PIN CONTENT THAT WAS NEVER PUBLISHED, and nothing heals it automatically: `go mod tidy` VERIFIES before it rewrites, so it refuses to fix the very lines that are wrong. Delete both lines for the bad version — the `h1:` and the `/go.mod` — then re-run tidy, and cross-check what it writes against sum.golang.org by hand if GOPRIVATE covers the org (with GOPRIVATE set, the checksum db is never consulted, so a wrong hash can be re-recorded unnoticed). The workspace masks this entire failure class — go.sum is not consulted under go.work — so verify the repair with GOWORK=off, from a clean module cache. - GOLDENS REGENERATE IN THE TASK THAT MOVES THEM — never in a follow-up, and the commit body says so. Anything that changes rendering (a token, a face, a density) moves golden images in every repo downstream. The regeneration flag is per test binary and MUST FOLLOW the package list: `go test ./button ./input ... -golden.update` — placed before the packages, `go test` hands `./...` to the test binary and silently tests the current directory only. And `./...` works only where every test package has goldens (markdown); in prism, pulse, spectrum and cadence, packages without goldens reject the undeclared flag, so name the golden packages one by one (each repo's AGENTS.md carries the measured command line). A golden also PINS ITS FACES with tokens.DefaultTypography.DeterministicShaper(), never the application default Shaper() (§Typography) — and workbench/watchlist stores its goldens in `testdata/` directly, not `testdata/golden/`, so a sweep keyed on the conventional path silently skips it. - TAGS ARE FOREVER, SO SPEND THEM CAREFULLY. No version component ever reaches two digits (v0.0.9 → v0.1.0, never v0.0.10); once the proxy or sum.golang.org has seen a version it is immutable, and a wrong tag can only be buried by the next number, never reused. While a tag is in flux, check state with `git ls-remote` — probing proxy.golang.org or sum.golang.org CAUSES the version to be recorded. Nested-module tags mirror their root's version (§Nested modules); a nested tag never runs on its own counter. ## Minimal go.mod for a new app module example.com/myapp go 1.25.1 require ( gioui.org v0.10.1 github.com/reactivego/rx v0.3.0 github.com/vibrantgio/cadence v0.3.1 github.com/vibrantgio/mvu v0.4.4 github.com/vibrantgio/prism v0.3.1 github.com/vibrantgio/spectrum v0.4.0 github.com/vibrantgio/textdraw v0.0.4 ) These are the released tags — the same ones §Modules lists, and the same ones all seven workbench apps are pinned to. Typography and colour need no module of their own: both travel on the theme, which comes with spectrum; 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 spectrum's dependency; `go mod tidy` marks it `// indirect` and only promotes it if you import a face package yourself. Add markdown/pulse/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. As of Phase F1 all seven apps embody the current contract — spectrum 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), prism button/checkbox/list, a modal dialog, live light/dark; theme.go is the minimal token-adapter recipe (PaletteFrom resolving ADR-007 steps, TypeFrom picking Typography roles) iconbrowser/ searchable Material-icon grid — prism TextField with per-keystroke MessageOp updates, subscription-scoped scroll and editor state, ivg icon rendering at scale watchlist/ full CRUD + persistence + context menus + popover confirms; wiring_test.go shows how to test the AutoConnect count 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). Also: SplitPane shell with a collapsible, resizable sidebar, trash-backed session-wide undo (Cmd/Ctrl-Z), the modal-form recipe (Seed/FocusTag/DynamicFocusTags/HideClose), 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 launcher/ the workbench front door — 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 + cadence cards over it, and one streaming mvu.Command per launched app (Started, then Exited when the process ends) 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) sitedocs/ marketing + docs shell: hero/feature/pricing/testimonial, accordion sidebar, breadcrumbs; the docs pages are embedded .md sources rendered with vibrantgio/markdown (content/*.md + docs_content.go route table — the docs-app recipe, including the light/dark highlight switch) DESIGN.md the architecture document — read §Key Architectural Patterns and §Threading & Lifecycle before writing new components