Skip to content

Omadia UI — Visual Specification

Archived snapshot — v0.1

First draft — tokens, primitives, idioms. Reconstructed from commit 0a977bb (2026-05-18, Christian Wendler). View latest → · Version history →

The single shipped Omadia theme. Tokens, per-primitive visuals, composition idioms, motion language. Precise enough that two independent implementers produce the same result.

Version 0.1 — first draft, written against ../CONCEPT.md v0.7, ./walkthroughs.md, ./tech-stack.md. Codex-review-ready in the CONCEPT.md cadence (2–3 review rounds expected before implementation freeze).


0. How to read this document

  • All values are semantic tokens, never raw #hex. Implementers consume them through a tokens module; raw values appear in exactly one place — the token definitions in §1. Anything that reaches for a hex code outside §1 is a bug.
  • Tables are the primary format. ASCII wireframes appear where layout matters more than pixel-precise visuals. Pixel mockups for canvas-region, timeline, media are explicitly out of scope (§7).
  • Rationale blocks appear under headings prefixed "Rationale —". They document the alternatives that were weighed; reviewers should challenge the rationale, not just the choice.
  • The spec is normative for v1. Forward-compat notes (e.g. shared-canvas presence surfaces) are advisory.

Non-negotiable constraints inherited from CONCEPT.md

  1. Single shipped theme. No skinning, no era mimicry. Era references resolve to layout idioms, not visuals.
  2. macOS-first. Windows next, Linux power-user subset. macOS rendering quality is the bar.
  3. Data-dominant typography. Data has visual weight; chrome recedes. Hierarchy is typographic, not chromatic.
  4. One accent slot. No status-pill salad (rot/grün/gelb verboten).
  5. Skeletons, no spinners for loading (single documented exception in §5).
  6. Keyboard-first. Visible focus, ⌘K palette, full arrow-key reach.
  7. Editor-class first-class. canvas-region, timeline, media, vector-path must render credibly in the same theme that renders a table.

Reference apps (orientation, never mimicry)

  • Linear — typography rigour, density, focus rings, command palette UX.
  • Things 3 — restraint, generous whitespace, single soft accent.
  • Raycast — Spotlight idiom; compact result lists; mono-leaning utility feel.
  • Apple Design Resources (latest) — native macOS rhythm, control shape, motion.
  • Notion (light mode) — typographic hierarchy, content-dominant pages.
  • Tremor — restrained, on-brand charts; muted palette discipline.
  • shadcn-ui — component composability and token discipline.

Explicitly NOT references: Confluence, Microsoft Teams, JIRA Cloud (enterprise clutter), Figma sidebars (too many panels), Slack (single-conversation paradigm).


1. Design Tokens

All colour tokens are expressed in OKLCH with L C h. OKLCH is chosen over HSL because:

  • Perceptual L: same L value reads at the same lightness across hues. HSL fails here — hsl(60 100% 50%) (yellow) is visually much lighter than hsl(240 100% 50%) (blue) at the same L.
  • Wide-gamut friendly. Display-P3 on modern Macs renders Omadia's accent at higher chroma than sRGB without re-authoring tokens.
  • Trivially convertible to sRGB hex for legacy renderers. Conversion lives in the token-build step, not in product code.

If a renderer can't consume OKLCH directly (older CSS engines, native Cocoa drawing APIs without colour-space conversion), the token-build step emits a parallel sRGB hex map. OKLCH is the source of truth, sRGB hex is generated.

1.1 Colour — Light mode

Background hierarchy

TokenOKLCHsRGB approxUse
bg.canvas0.99 0.002 250#FCFCFDWorkspace background (the agent's "blank page")
bg.surface0.985 0.003 250#FAFAFCPrimary content surface inside containers
bg.surface.raised1.00 0 0#FFFFFFCard-like raised surfaces, popovers, inputs
bg.surface.sunken0.97 0.004 250#F4F4F7Code blocks, table cell hover, secondary panels
bg.modal.overlay0.20 0.01 250 / 0.40rgba(black,0.40)Scrim behind modal panes
bg.modal.surface1.00 0 0#FFFFFFModal pane interior

Text hierarchy

TokenOKLCHsRGB approxUse
text.primary0.22 0.01 250#1B1D24Headings, body, data values
text.secondary0.45 0.01 250#5B5F6BLabels, captions, axis ticks
text.tertiary0.62 0.01 250#8D9099Hints, placeholders, low-priority metadata
text.disabled0.78 0.005 250#BFC1C6Disabled controls
text.inverse0.99 0.002 250#FCFCFDText on accent or dark surfaces
text.accent0.55 0.16 235#0F7AB8Links, accent-emphasised values

Border

TokenOKLCHsRGB approxUse
border.subtle0.93 0.004 250#E6E7EBDefault container, table cell, divider
border.default0.88 0.005 250#D6D7DBInput borders, button outlines
border.strong0.72 0.008 250#A4A6ACPressed state, prominent edges
border.focus0.55 0.16 235#0F7AB8Focus ring (= accent)

Accent (the single slot)

TokenOKLCHsRGB approxUse
accent0.55 0.16 235#0F7AB8Primary actions, focus rings, selection edges
accent.hover0.50 0.17 235#0C6CA8Hover state for accent fills
accent.active0.45 0.17 235#0A5E94Pressed state for accent fills
accent.subtle0.96 0.025 235#E5F0F8Selected row tint, accent background wash
accent.subtle.hover0.93 0.035 235#D6E7F2Hover over already-selected accent-subtle rows

Semantic states (intentionally muted — see Rationale)

TokenOKLCHsRGB approxUse
state.loading0.90 0.008 250#DCDEE3Skeleton fill base
state.loading.hi0.96 0.004 250#EFEFF2Skeleton pulse highlight
state.error.fg0.45 0.12 25#A8443BError text — never used as a pill background
state.error.edge0.55 0.14 25#C45A50Error border on a field (1px, not a block fill)
state.success.fg0.42 0.10 150#3F7A55Confirmation text — text only, no green pill
state.warning.fg0.50 0.09 80#8C6A1FWarning text — text only, no yellow pill

Rationale — semantic states as text-only, never as filled pills. Three reasons:

  1. CONCEPT.md forbids the status-pill salad by name. Implementers reading the constraint will reflexively reach for a red badge — we make that impossible by not shipping bg.error / bg.success / bg.warning tokens.
  2. In a data-dominant UI, the row, the value, the column header carry the meaning. "AcmeInsure overdue" is louder when "overdue" sits next to "AcmeInsure" in body text than when a tomato badge floats next to it.
  3. Single accent already covers "this thing is selected / focused / actionable". That is what users scan for. Adding more colour categories competes with the accent and dilutes it.

Where a state needs visual weight (an error on a form field, a failed sub-agent), the affordance is: 1px coloured border + inline message in coloured text. No filled pill, no large filled block. See §4.13 form and §5.3 error patterns.

Rationale — single accent: the colour choice

Candidates considered, with single-line summary:

CandidateOKLCH approxWhyWhy not
Linear indigo (#5E6AD2-ish)0.55 0.17 280Familiar, signals "modern productivity"Too close to Linear's brand; we don't want to read as a Linear clone
Things sky-blue0.65 0.15 240Calm, softReads as "consumer app", not enough gravity for editor workloads
Raycast bright red0.60 0.21 25Distinctive, energeticRed as accent collides with state.error.*; same-channel ambiguity
Selected — petrol/steel-blue0.55 0.16 235Cool, slightly desaturated; signals "synthesised, live, on-canvas"; clearly not Linear; reads well in dark mode at higher chroma; mathematically separable from any error-red usage
Warm copper0.62 0.13 60Differentiated from every reference appRisk: too "branded", drifts toward designer-app aesthetic

The selected accent (#0F7AB8 ≈ OKLCH 0.55 0.16 235) is a desaturated petrol/steel-blue at 235°. Cool enough to recede when used as a row tint, saturated enough to anchor focus rings and primary CTAs. Distinct from every reference app listed; distinct from state.error.* (25°) by 210° on the hue circle, so colour- blindness simulators don't merge them.

1.2 Colour — Dark mode

Same semantic structure, OKLCH-flipped. The relationship between tokens is preserved (e.g. text.primary stays the most prominent text token).

Background hierarchy

TokenOKLCHsRGB approxUse
bg.canvas0.16 0.01 250#1F2127Workspace background
bg.surface0.19 0.01 250#262830Primary content surface
bg.surface.raised0.22 0.012 250#2C2F38Raised surfaces, popovers, inputs
bg.surface.sunken0.14 0.01 250#1A1C22Sunken (code, hover, secondary)
bg.modal.overlay0.05 0.005 250 / 0.60rgba(black,0.60)Modal scrim
bg.modal.surface0.22 0.012 250#2C2F38Modal pane interior

Text hierarchy

TokenOKLCHsRGB approxUse
text.primary0.96 0.005 250#EEEFF3Headings, body, data values
text.secondary0.75 0.008 250#B6B9C3Labels, captions
text.tertiary0.58 0.008 250#888B95Hints, placeholders
text.disabled0.38 0.008 250#525561Disabled
text.inverse0.16 0.01 250#1F2127Text on accent / inverse
text.accent0.72 0.13 235#52B0E2Links, accent-emphasised values

Border

TokenOKLCHsRGB approxUse
border.subtle0.27 0.012 250#363944Default container edges
border.default0.34 0.013 250#454854Input borders
border.strong0.50 0.014 250#71747FPressed, prominent
border.focus0.72 0.13 235#52B0E2Focus ring (= accent in dark mode)

Accent — Dark mode

TokenOKLCHsRGB approxUse
accent0.72 0.13 235#52B0E2Primary actions, focus rings, selection edges
accent.hover0.78 0.13 235#74C0E8Hover
accent.active0.82 0.12 235#90CFEEPressed
accent.subtle0.32 0.04 235#2C404FSelected row tint
accent.subtle.hover0.36 0.045 235#34495AHover over selected

Semantic states — Dark mode

TokenOKLCHsRGB approxUse
state.loading0.30 0.01 250#3E414CSkeleton base
state.loading.hi0.38 0.012 250#525561Skeleton highlight
state.error.fg0.75 0.12 25#E08577Error text
state.error.edge0.65 0.14 25#C5685AError border
state.success.fg0.78 0.10 150#88C499Success text
state.warning.fg0.80 0.09 80#D6B468Warning text

Rationale — accent flip in dark mode. The light-mode accent at L=0.55 reads as "deep blue" against white. In dark mode the same L drowns in the background. The dark-mode accent moves to L=0.72 with reduced chroma (0.13 → preserves identity). Test: place both accents in their respective modes next to body text — both should read at the same "perceptual weight". Implementers verifying the dark theme should not use light-mode accent literally.

Theme switching

  • macOS: follow system appearance by default; user can override via ⌘K → "Set appearance to ...".
  • Windows / Linux: follow OS dark-mode preference; user override identical.
  • No mid-session animation between modes (CONCEPT.md "instant deterministic rendering" principle). Theme swap is paint-only, no transitions.

1.3 Typography

Type families

RoleFamilyFallback
UI sansInter (variable)system-ui, -apple-system, "Segoe UI", sans-serif
MonoJetBrains Monoui-monospace, "SF Mono", Menlo, Consolas, monospace
Display (rare)Inter DisplaySame as UI sans (variable axis covers display)

Rationale — Inter, not SF Pro. SF Pro is the native macOS system font and would read most native there. But:

  • Electron is the chosen runtime (docs/tech-stack.md). Inter rendering through Skia is identical on macOS, Windows, Linux. SF Pro is licensed for macOS Cocoa rendering, not freely embeddable.
  • Linear, Vercel, Notion, Things 3 (newer builds) all use Inter or a near-Inter variant — the reference apps. Users reading Omadia next to Linear will not feel a typeface clash.
  • Inter's variable axis (weight 100–900, slant) lets us ship one file and address every weight we need. SF Pro requires separate font assets per weight.

Rationale — JetBrains Mono, not SF Mono / Menlo. JetBrains Mono has:

  • Generous x-height (data tables read at smaller sizes without losing legibility).
  • Distinguishable 0 / O, 1 / l / I (correctness matters for financial data — Walkthrough 1's ERP budget column).
  • Open ligatures (->, >=) — useful in code blocks and TUI-style layouts where the agent renders bash output or terminal-style diffs.

Mono is not a stylistic flourish. It is the typographic anchor for the "Norton Commander" idiom (data-grid panels, terminal-feel data dumps), for code blocks in research walkthroughs (Walkthrough 4), and for any column where digit alignment matters (table financial columns). The single-mono-everywhere choice makes the Omadia idiom coherent.

Type scale (semantic)

All sizes are in rem, base 1rem = 16px. Line heights are unitless.

TokenSizeLine heightWeightLetter-spacingRole
type.display1.75rem (28px)1.20600-0.01emRare. Welcome surfaces, top-level pane title in editor workspace
type.heading.11.375rem (22px)1.25600-0.005emCanvas-level title, top of a container group
type.heading.21.125rem (18px)1.306000emSection heading inside a container
type.heading.30.9375rem (15px)1.356000emSub-section, table-group header
type.body0.875rem (14px)1.504000emDefault UI text, paragraph copy
type.body.strong0.875rem (14px)1.506000emInline emphasis; column-header text in tables
type.body.compact0.8125rem (13px)1.454000emDense table rows, style: "compact" containers
type.caption0.75rem (12px)1.404000.005emLabels above inputs, timestamps, axis ticks
type.caption.strong0.75rem (12px)1.406000.02emUppercase eyebrow labels (sparingly)
type.mono.data0.8125rem (13px)1.454500emNumeric table cells, code snippets, terminal lines
type.mono.code0.8125rem (13px)1.554000emMulti-line code blocks

Weight 450 for mono.data uses a variable-axis intermediate weight: slightly heavier than 400 so digits hold up against denser table backgrounds, but lighter than 600 so they don't shout.

Rationale — no Helvetica-massive headings. A typical "marketing UI" tops out at 40–60px display. We cap at 28px because:

  • Omadia is a work surface, not a landing page. There is no hero.
  • Data is the protagonist. Headings above 28px steal attention from data.
  • The largest text on screen at any given moment should be either a text primitive used as a hero quote (rare, user-chosen) or a single heading.1 per canvas.

Letter-spacing and weight discipline

  • Headings: tighter than body (-0.005em to -0.01em). Counteracts the visual loosening that bigger sizes cause.
  • Caption-strong (uppercase eyebrows): wider tracking (+0.02em). Uppercase always needs more space.
  • Body: zero tracking. Inter is metrically correct at body sizes.
  • Weights used: 400, 450 (mono only), 600. Anything else is forbidden — no 300 (too light at body sizes), no 500 (collides with 600 perceptually), no 700+ (heavy weights compete with accent).

1.4 Spacing

4pt grid. Not 8pt — 4pt offers the density required for editor workloads (toolbar button spacing, inspector field rows) without forcing implementers to use fractional values.

TokenValueUse
space.00Touching edges
space.12pxHairline gap between same-group icons; sub-pixel-feeling adjustments
space.24pxDefault gap inside a tight group (input + clear button)
space.38pxDefault gap in a row of controls, between list items in compact density
space.412pxDefault block spacing inside a container; default gap of a stack
space.516pxDefault container padding; section gap
space.624pxGenerous block spacing; spacious density default
space.732pxTop-of-canvas padding; sectional dividers
space.848pxMajor layout gaps (left rail to content area)
space.964pxReserved for explicit "breathing-room" placements (rare)

Density variants apply a per-primitive override (see §4): style: "compact" shifts defaults one step down, style: "spacious" shifts one step up.

1.5 Border radii

TokenValueUse
radius.00Tables, panes, canvas-region (engineering surfaces)
radius.sm4pxInputs, buttons, list-item hover/selected backgrounds
radius.md6pxContainers, cards, popovers
radius.lg8pxModals, raised cards (only one elevation step above md)
radius.pill999pxPill chips (badge primitive, rare; status indicators)

Rationale — restrained radii. Things 3 uses ~10px on cards; Notion ~6px on blocks; Linear ~6–8px. We pick 6px for containers because the canvas is dense; a larger radius would create visible empty corners between adjacent containers and break the data-grid feel.

radius.0 exists explicitly so editor workloads (Photoshop workspace) have sharp-corner surfaces. A pixel editor with rounded corners reads "consumer photo app", not "professional tool".

1.6 Elevation / shadows

Sparing. Almost everything sits flat on bg.canvas or bg.surface. Elevation is for temporally raised surfaces: popovers, dropdowns, modals, drag-in-flight.

TokenLightDarkUse
elev.0nonenoneFlat content surfaces
elev.popover0 1px 2px rgba(0,0,0,0.04), 0 4px 12px rgba(0,0,0,0.06)0 1px 2px rgba(0,0,0,0.30), 0 4px 12px rgba(0,0,0,0.40)Dropdowns, popovers, hover cards
elev.modal0 4px 8px rgba(0,0,0,0.06), 0 16px 32px rgba(0,0,0,0.10)0 4px 8px rgba(0,0,0,0.40), 0 16px 32px rgba(0,0,0,0.55)Modal panes
elev.drag0 8px 24px rgba(0,0,0,0.16)0 8px 24px rgba(0,0,0,0.50)Drag-in-flight ghost preview

No "card" elevation. Cards are differentiated by border + radius, not by shadow. This is a deliberate departure from Material Design and an alignment with Linear / Things / Apple Catalyst.

1.7 Motion

TokenValueUse
motion.instant0msTheme switch, scroll jumps, focus moves on tab nav
motion.quick100msHover state, focus ring fade-in
motion.smooth200msModal open/close, accordion expand, patch fade-in
motion.deliberate320msReserved — used only for canvas-activate transitions between Spaces
easing.standardcubic-bezier(0.22, 0.61, 0.36, 1.00)Most transitions (decelerate-out)
easing.emphasiscubic-bezier(0.4, 0.0, 0.2, 1.0)Bigger moves (modal scale-in, full snapshot replace)
easing.linearlinearSkeleton pulse

Skeleton pulse

@keyframes skeleton-pulse:

text
0%   { background-position: -200% 0; }
100% { background-position:  200% 0; }
  • Duration: 1400ms, linear, infinite.
  • Gradient: linear-gradient(90deg, state.loading 0%, state.loading.hi 50%, state.loading 100%) at 400% width.
  • Reduced-motion: pulse disabled, skeleton renders as static state.loading fill.

Reduced motion

When the OS reports prefers-reduced-motion: reduce:

  • motion.quick0ms. (Hover/focus changes still happen, just without fade.)
  • motion.smooth0ms. (Modal opens instantly.)
  • motion.deliberate0ms.
  • Skeleton pulse → static.
  • Patch-apply highlight (§5.1) → no fade-out; the highlight simply isn't drawn.

1.8 Icons

Library: Lucide. Stroke-based, MIT-licensed, ~1100 icons, actively maintained, React/Vue/Svelte/HTML bindings — covers every Electron renderer choice.

Heroicons was the runner-up; rejected because:

  • Two-style split (outline vs. solid) tempts implementers to mix metaphors.
  • Smaller icon set; editor workloads (brush, magic-wand, vector-pen, timeline- scrub) hit gaps faster.

Custom icons forbidden in v1 except where Lucide has no equivalent:

  • Three documented exceptions allowed: magic-wand (selection tool), brush-pressure (pressure-sensitive brush variant), vector-pen-anchor (path-anchor handle). These ship in assets/icons/custom/ and follow Lucide stroke/width conventions exactly.

Icon sizes (semantic)

TokenSizeStrokeUse
icon.xs12px1.5Inline with caption text
icon.sm14px1.5Inline with body text
icon.md16px1.75Buttons, toolbar default
icon.lg20px1.75Tab indicators, prominent toolbar tools
icon.xl24px2.0Empty-state illustrations (centred glyph)

Stroke width scales with size — preserves perceived weight. Lucide ships configurable stroke width; the token-build step bakes the right values.


2. Per-Primitive Visual

For each of the 24 primitives from CONCEPT.md §"The Primitive Vocabulary", this section specifies: default visual, interactive states, variants, density behaviour, and edge cases (empty, overflow, error). ASCII wireframes accompany layout-heavy primitives.

Conventions:

  • "Default density" = no style override; behaves as if style: "default".
  • "Compact" / "Spacious" = style.density override.
  • "Selected" applies only when the primitive has a selection trait.
  • Tokens referenced by name (bg.surface, accent, …); resolve via §1.

2.1 text

Block or inline copy. The workhorse of agent prose.

StateVisual
Defaulttype.body / text.primary. No background, no border.
Inside heading groupInherits font from heading; otherwise default
Long proseMax width 72ch when not constrained by parent; left-aligned
Inline emphasistype.body.strong for <strong>-equivalent semantics
Inline codetype.mono.data on bg.surface.sunken with radius.sm padding 0 4px

Density:

  • Compact: type.body.compact.
  • Spacious: increased line-height to 1.6 (no size change).

No hover, no focus (text is not interactive). Selection (text-selection by mouse) uses native OS behaviour with accent.subtle highlight tint.

2.2 heading

Section title. Always renders inside a container or pane.

LevelTokenMargin-top (within container)
1type.heading.1First child: 0; otherwise space.6
2type.heading.2First child: 0; otherwise space.5
3type.heading.3First child: 0; otherwise space.4

Margin-bottom inside a heading is always space.3.

Variants:

  • style.divider: true — adds a border.subtle underline running the full content width. Used when the agent wants visual separation under a section heading.
  • style.eyebrow: true — renders type.caption.strong (uppercase eyebrow) above the heading at text.tertiary.

2.3 container

Grouping primitive. Optional title, optional border, optional padding.

┌──────────────────────────────────────────────┐
│ Optional heading                             │
│                                              │
│  child                                       │
│  child                                       │
│                                              │
└──────────────────────────────────────────────┘

Default (no title, no border, no shadow): 0 padding, behaves as a flex group with gap: space.4.

VariantVisual
border: true1px solid border.subtle, radius.md, padding space.5
title: <string>heading.3 at top, space.3 bottom margin
style: compactpadding space.3, gap space.3
style: spaciouspadding space.6, gap space.5
style: sunkenbg.surface.sunken, no border by default
style: raisedbg.surface.raised, 1px solid border.subtle, radius.md

Empty container with title only: shows title and a tertiary-text placeholder hint if and only if the agent provides placeholder. Never ships a default "This container is empty" string.

2.4 list

Ordered collection. Vertical by default.

─── item label                            ─┐
                                           │ row height: 32px (default)
─── item label                            ─┤
                                           │  hover: bg.surface.sunken
─── selected item                         ─┤  selected: accent.subtle + 2px left bar in accent

─── item label                            ─┘
Mode (selection)Visual
nonePlain rows, hover bg only
singleSelected row: accent.subtle + 2px accent left bar; hover otherwise
multiSame as single + leading checkbox indicator (toggle primitive embedded)

Density:

DensityRow heightPadding (x/y)Type token
Compact28pxspace.3 / space.2type.body.compact
Default32pxspace.4 / space.3type.body
Spacious40pxspace.5 / space.4type.body

Focus: keyboard-focused item draws a 2px border.focus ring inset by 2px (so the ring doesn't overlap neighbours). Arrow up/down moves focus; Enter triggers the item's action.

Empty: tertiary-text inline hint, 1 line, agent-authored. No icon, no illustration.

Overflow: rows render as-is up to virtualised threshold (declared via trait virtualized: true). Past 200 rows without virtualisation: implementer logs a warning; rendering is still correct but degrades.

2.5 table

Rows × columns. The data-aggregation workhorse.

┌─────────────────────────────────────────────────────────────────┐
│ OWNER          OPEN TICKETS    BUDGET LEFT (h)    STATUS         │ ← header row
├─────────────────────────────────────────────────────────────────┤
│ Anna Schmidt          12               5.0        out of budget  │
│ Bernd Lutz             8               7.5        under budget   │
│ Cara König            15              22.0        ok             │
└─────────────────────────────────────────────────────────────────┘
  • Header: type.body.strong, text.secondary, uppercase tracking optional (per-table flag, default off). Row separator: 1px border.subtle below header.
  • Body rows: type.body for text columns, type.mono.data for numeric columns (detected by column declared kind: 'number' | 'currency' | 'count').
  • Numeric columns right-aligned. Text columns left-aligned. No vertical separators by default.
  • Row height: 36px default, 30px compact, 44px spacious.
  • Zebra: off by default. Reading data depends on alignment, not stripes. style.zebra: true enables alternating bg.surface.sunken on every second row; the agent uses this for very wide tables.
  • Hover: bg.surface.sunken on hovered row.
  • Selection (single / multi): same accent treatment as list. Multi-select shows a checkbox in a leading column.
  • Sort indicators: small caret in text.tertiary next to the active sort column; on hover of a sortable column header, caret appears in text.secondary.
  • Sticky header on scroll: header stays at top, gets 1px solid border.subtle shadow separator (no elev.popover, the shadow is purely a divider).

Loading rows:

┌─────────────────────────────────────────────────────────────────┐
│ OWNER          OPEN TICKETS    BUDGET LEFT (h)    STATUS         │
├─────────────────────────────────────────────────────────────────┤
│ ▓▓▓▓▓▓▓▓▓▓▓     ▓▓▓▓▓▓▓        ▓▓▓▓▓▓▓             ▓▓▓▓▓▓▓▓▓▓   │ ← skeleton row
│ ▓▓▓▓▓▓▓▓▓       ▓▓▓▓▓▓▓        ▓▓▓▓▓▓▓             ▓▓▓▓▓▓▓▓     │
│ ▓▓▓▓▓▓▓▓▓▓▓▓    ▓▓▓▓▓▓▓        ▓▓▓▓▓▓▓             ▓▓▓▓▓▓▓▓▓    │
└─────────────────────────────────────────────────────────────────┘

Skeleton cell width per row is randomised but stable for the lifetime of that row identifier (so it doesn't flicker between repaints).

Empty: row-area replaced with a single centred tertiary-text line at half-height of a typical row.

Variant — highlighted row: a row with the cross-cutting trait style.emphasis: "accent" renders with accent.subtle background and no left bar (left bar is reserved for selection — must remain unambiguous). This is what Walkthrough 1 step 13 uses to flag "under budget" rows.

2.6 tree

Hierarchical list. Also serves as layer-stack when carrying editor traits.

▾ Group                              hover: bg.surface.sunken
   ▾ Subgroup
       Item                          focus: 2px border.focus inset
       Item (selected)               selected: accent.subtle + 2px accent left bar
   ▸ Subgroup (collapsed)
Item
  • Indent: space.4 per level.
  • Expand/collapse caret: icon.sm chevron, text.tertiary default, text.secondary on hover.
  • Selection visuals: identical to list.
  • Drag handle (layer-stack mode only): appears on hover at the right edge of the row, icon.sm grip-vertical, text.tertiary.
  • Layer trait (when present): row shows leading 16×16 thumbnail (canvas-region preview) + visibility toggle (eye icon) + opacity slider on a row hover-popover.

Performance: implementers must implement either virtualisation (preferred for large trees) or progressive disclosure (collapse-by-default past depth N).

2.7 button

Action trigger.

VariantBackgroundBorderTextUse
primary (default if accent: true)accentnonetext.inverseOne per surface; e.g. "Send", "Generate"
secondarytransparent1px border.defaulttext.primaryToolbar default, "Cancel" siblings
ghosttransparentnonetext.primaryIcon-only buttons in toolbars
dangertransparent1px state.error.edgestate.error.fgDestructive confirm in modals (rare)

Sizes (uniform across variants):

SizeHeightPadding (x)Type tokenIcon size
Compact24pxspace.3 (8px)type.body.compacticon.sm
Default32pxspace.4 (12px)type.bodyicon.md
Spacious40pxspace.5 (16px)type.bodyicon.md

States:

StatePrimarySecondaryGhost
Hoverbg → accent.hoverbg → bg.surface.sunkenbg → bg.surface.sunken
Activebg → accent.activebg → bg.surface.sunken, border darkerbg → bg.surface.sunken
Focus2px border.focus ring, 2px offsetsamesame
Disabledbg → state.loading, text → text.disabledborder → state.loadingtext → text.disabled
Loadingspinner not allowed — see §5; button shows type.body.compact "Working…" replacing label with marquee dots animationsamesame

Rationale — single spinner exception, only for buttons. A button that performs an external-effect action (Walkthrough 3 step 20: "Send") cannot show a skeleton (the button has no content to skeletonise). Three documented options considered:

  1. Disable the button, change label to "Sending…", no visual motion. Risk: user thinks the click didn't register.
  2. Add a tiny inline spinner glyph. Violates CONCEPT.md skeleton-only rule.
  3. Selected — replace label with "Sending…" plus animated marquee dots (Sending., Sending.., Sending... at motion.quick * 4 interval). No spinner glyph, no ring. Conveys progress without a circle.

This is the single skeleton-rule exception, scoped to button-in-flight. The exception lives here, in the spec, and is documented at the §5 anti-pattern list.

2.8 input

Text entry.

┌──────────────────────────────────────┐
│ Placeholder text…                    │   default: 1px border.default
└──────────────────────────────────────┘
                                          focus: 2px border.focus (inset, no offset)
                                          error: 1px border = state.error.edge
SizeHeightPadding (y/x)Type
Compact28pxspace.2 / space.3type.body.compact
Default32pxspace.3 / space.3type.body
Spacious40pxspace.4 / space.4type.body

States: hover bg → bg.surface, focus inset 2px border.focus, error 1px state.error.edge + inline message below input at state.error.fg and type.caption. Disabled: bg → bg.surface.sunken, text → text.disabled.

Variants:

  • multiline: true → renders as a textarea, min-height 96px, vertical resize handle in bottom-right (16×16 grip glyph at text.tertiary).
  • leadingIcon / trailingIcon → icon size icon.md, text.tertiary, inset by space.3 from input edge; input text padding shifts to leave room.
  • password: true → reveal-toggle icon in trailing slot, eye-off / eye.

2.9 choice

Single-select from N. Renders as dropdown by default, radio group when the agent sets style.layout: "inline".

┌──────────────────────────────────────────┐
│ Selected option                       ▾  │   trigger: button-secondary look
└──────────────────────────────────────────┘
       ↓ click / Enter / Space
┌──────────────────────────────────────────┐
│  Option A                                │   open: elev.popover, radius.md
│  Option B  ✓                             │   selected option: leading check
│  Option C                                │   keyboard: arrow up/down, Enter to pick
└──────────────────────────────────────────┘
  • Trigger same dimensions as input.
  • Open menu: bg.surface.raised, border.subtle, radius.md, elev.popover.
  • Item hover: bg.surface.sunken. Item focus (keyboard): 2px border.focus inset. Selected item: leading checkmark icon.sm in accent.
  • Max-height 320px before scroll; overflow scrolls vertically with no scrollbar by default (overlay scrollbars).

Radio variant (inline)

( ) Option A    ( ) Option B    (•) Option C
  • Circle: 16px outer, 1px border.default, inner dot 6px accent when selected.
  • Focus: 2px border.focus ring around the outer circle, 2px offset.
  • Inline layout: horizontal flex, gap space.5.

2.10 toggle

Boolean. Two visual forms — checkbox by default for in-form use, switch for on/off-of-a-feature mental model (set via style.layout: "switch").

Checkbox (default)

[ ] Label text          off
[✓] Label text          on:  accent fill + text.inverse check glyph
  • Box: 16×16, radius.sm, 1px border.default off, accent fill on.
  • Indeterminate: accent fill, text.inverse minus glyph.
  • Focus: 2px border.focus ring, 2px offset.

Switch (style.layout: "switch")

( ●─ )  off:  bg = bg.surface.sunken, knob = bg.surface.raised + 1px border.default
( ─● )  on:   bg = accent, knob = bg.surface.raised
  • Dimensions: 28×16 track, 12×12 knob, knob inset 2px.
  • Transition: knob slide motion.quick / easing.standard.

2.11 image

Static bitmap content.

  • Rendered with object-fit: contain by default; agent can override via style.fit: "cover" | "contain".
  • Loading: skeleton with the image's known aspect ratio (agent passes width / height in props; renderer uses them as the skeleton box).
  • Error: container at the same dimensions, centred image-off icon at icon.lgtext.tertiary, no text (consistent with the empty-state restraint).
  • No border / no shadow by default. style.border: true adds 1px border.subtleradius.md.

2.12 chart

Static data-driven visual. v1 supports bar, line, pie. Implementations should use a small library (Tremor's primitives or Recharts via Visx for full control) — but the visual language is normative:

  • Single accent for the primary series. Additional series use accent variations on the chroma axis (drop chroma to 0.06 for series 2; chroma 0.04 for series 3) at the same L. Never multi-hue.
  • Grid lines: border.subtle, dashed 1px on Y-axis, no X-axis grid.
  • Axis labels: type.caption, text.tertiary.
  • Value labels: type.mono.data, text.secondary, shown on hover only.
  • Tooltip on hover: bg.surface.raised, elev.popover, radius.md, padding space.3. Content: series name (type.body.strong) + value (type.mono.data).
  • No legend if there's only one series. Multi-series: legend below chart in type.caption, text.secondary, with 8px swatch squares matching the series chroma reduction.

Empty: chart with no data renders the axes only and a tertiary-text inline hint at chart centre.

2.13 form

Group of inputs + submit. When carrying the context-binding trait (CONCEPT.md §"Editor primitives"), this primitive is the inspector in editor workspaces.

Default layout: vertical stack of labelled rows.

Label                                              ← type.caption.strong, text.secondary
┌──────────────────────────────────────────┐
│ value                                    │       ← input
└──────────────────────────────────────────┘
Optional helper text                               ← type.caption, text.tertiary

Label
[✓] Toggle option
( ) Choice A    (•) Choice B

[ Submit ]   [ Cancel ]                             ← toolbar at bottom: primary + secondary
  • Row gap: space.4 (default), space.3 (compact), space.5 (spacious).
  • Submit button: primary variant, default size. Cancel: secondary, same size.
  • Inline error: under the input at state.error.fg, type.caption.
  • Form-level error (e.g. "Send failed — try again"): banner above the submit row, 1px state.error.edge left border, bg.surface background, padding space.4, text in state.error.fg.

Inspector mode (form with context-binding trait):

  • Renders without a Submit button. Each input change emits its own action.
  • Compact density by default.
  • Labels render to the left of inputs (label-input grid), not above. Label column 40% width, input column 60%.

2.14 toolbar

Action strip. Horizontal flex of buttons (typically ghost variant) + optional separators.

┌────────────────────────────────────────────────────────────────┐
│ [ ⤴ ] [ ⤵ ] │ [ B ] [ I ] [ U ] │ [ ⬛ ] [ ⊙ ] │ ...   [ Send ] │
└────────────────────────────────────────────────────────────────┘
   undo  redo │  text styles      │  shape tools │       primary action
  • Height: 40px default, 32px compact, 48px spacious.
  • Padding: space.3 (x), 0 (y; buttons size themselves).
  • Background: bg.surface. Optional border.subtle 1px bottom (when toolbar sits above content) or top (when toolbar sits below content).
  • Separators: 1px border.subtle vertical, 16px tall, space.3 margin.
  • Primary action (if any): pushed to the right, never centred.

Vertical toolbar variant (left side of editor workspaces): same rules, rotated 90°. Buttons square (40×40 default), separators horizontal.

2.15 menubar

Cascading menu (top-of-window classic macOS-style menu, or context menu invoked by right-click).

Top-of-window menubar:

File  Edit  View  Canvas  Help
  • type.body at default density, text.primary.
  • Hover/open: bg.surface.sunken on the menu trigger.
  • Open menu: bg.surface.raised, radius.md, elev.popover, min-width 200px.
  • Menu items: 28px tall, padding space.3 x, type.body text, optional leading icon at icon.sm, optional trailing keyboard shortcut in type.caption.strong uppercase tracking at text.tertiary, right-aligned.
  • Item hover: accent.subtle background, text.primary.
  • Disabled item: text.disabled, no hover.
  • Separator: 1px border.subtle, full width inside the menu.

Context menu (right-click or long-press): identical to opened menu, no top-of-window trigger. Position: anchored to cursor point. Auto-flip to fit viewport.

2.16 tabs

Sibling containers with selector.

─── Tab one ──┬─── Tab two ──┬─── Tab three ──┬───────────────
              │                                 active tab edge
   inactive   │   inactive    │   inactive
─────────────────────────────────────────────── ← 1px border.subtle, full width
[ tab content ]
  • Tab labels: type.body, text.secondary inactive, text.primary active.
  • Active tab: 2px solid accent underline, sits flush with the 1px subtle border (overlaps it, so it appears "in front").
  • Hover (inactive): text.primary, no underline.
  • Focus: 2px border.focus ring, 2px offset, around the tab label itself.
  • Wizard variant (style.variant: "wizard"): tabs render as steps — see §3.2.

2.17 pane

Positionable, resizable container. The Miro-hybrid: technically a window, visually theme-driven (no per-window chrome, no traffic-light buttons inside the canvas — those belong to the OS window).

Default appearance:

  • bg.surface, radius.md, 1px border.subtle.
  • Drag handle: top 28px strip, type.caption.strong text.secondary title, cursor grab on hover.
  • Resize affordance: 8px hit-target along the right + bottom edges and the bottom-right corner; cursor changes to col-resize / row-resize / nwse-resize on hover. No visible drag handle glyph (the cursor is the affordance).
  • Pinned / unpinned: a pinned pane shows a pin icon in the title strip (icon.sm, text.tertiary); pinned panes cannot be dragged.

Modal variant (pane.kind: "modal"):

  • Centered in viewport (max 640px wide, max viewport-height - 64px tall).
  • elev.modal.
  • Scrim bg.modal.overlay covers everything underneath.
  • Title strip: type.heading.2 left, optional close x icon right.
  • See §5.4 for the full confirmation-modal pattern.

Drag in-flight:

  • Ghost preview at 50% opacity, elev.drag.
  • Drop targets highlight with 2px dashed accent border.

2.18 status

Read-only display. Used by the agent to surface state without occupying input attention: "Last synced 14:23", "Connected to ERP", "3 sub-agents working".

Layout: inline horizontal — optional leading icon (icon.sm, text.tertiary) + status text (type.caption, text.secondary). No background, no border.

For liveness (e.g. "3 sub-agents working" in Walkthrough 4), status may carry the loading trait — then the leading icon area renders a skeleton pulse-bar (8px wide, 12px tall, radius.sm) at state.loading / state.loading.hi.

2.19 progress

Progress of an ongoing operation. Linear bar, no circular spinner.

─── operation label                                  78%
████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░
  • Track: 4px tall, full available width, bg.surface.sunken, radius.pill.
  • Fill: accent, radius.pill.
  • Label (above the bar): type.caption text.secondary. Right-aligned numeric percent in type.mono.data text.secondary.
  • Indeterminate (no known percentage): fill renders as a 30%-width segment that travels back and forth across the track at motion.deliberate * 4 interval, reverses on each pass. prefers-reduced-motion: indeterminate static, fixed at left 0%.

2.20 divider

Visual separator. Horizontal 1px border.subtle, full width by default. Vertical variant available for in-toolbar use (see §2.14).

Variants:

  • style.thickness: "strong"border.strong instead of border.subtle. Rare; reserved for major canvas-level separations (e.g. left rail vs. content area).
  • style.dashed: true → 1px dashed border.subtle. Used for drop-zone indicators during drag.

2.21 media (editor-class)

Audio/video with playback controls.

┌─────────────────────────────────────────────────────────────┐
│                                                             │
│                        [video frame]                        │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│ [▶] [⏸] [⏹]    00:34 / 02:15    ─────●────────  [ 🔊 ─●── ] │
└─────────────────────────────────────────────────────────────┘
   transport    time           scrubber           volume
  • Frame area: bg.surface.sunken background, radius.md, video letterboxed with object-fit: contain. Poster (if provided) shown until first play.
  • Transport bar height: 40px. bg.surface, 1px border.subtle top.
  • Buttons: ghost-variant, icon.md. Time display: type.mono.data.
  • Scrubber: see vector-path / range-input visual — 4px track bg.surface.sunken, 3px buffered region border.default, 4px played region accent, 12px circular knob with accent fill, focus ring on knob 2px border.focus.
  • Volume: smaller scrubber, 80px wide.

For audio-only (mediaType: "audio"), the frame area is replaced by a waveform rendering using accent.subtle fill, accent for the played portion. Implementation note: visual mockup follows in mockup phase — waveform rendering detail is implementer choice within those colour constraints.

2.22 canvas-region (editor-class)

Pixel-editor region. Theme-wise the simplest primitive — it is deliberately visually plain so the user's image fills its content area.

  • Container: bg.surface.sunken, no radius (radius.0 — sharp corners for editor feel), no border by default; 2px accent border when this region is the active editing target.
  • Cursor: changes to match the active tool (declared by Tier-2 via tool-mode selection on the toolbar). The renderer maps tool identifier → CSS cursor (e.g. crosshair for selection, custom 24×24 PNG cursor for brush — implementer ships these alongside).
  • Selection-region overlay: 1px dashed line, animated dash-offset (marching ants). Dash pattern: 4px on, 4px off; offset increments by 1px per motion.quick, loops. Reduced-motion: static dash, no animation.
  • Zoom level indicator: bottom-right corner, type.mono.data, text.tertiary, padding space.3, bg.surface.raised chip with radius.sm.
  • Loading (during durable op or Tier-3 AI op): full-region overlay, bg.surface.overlay (50% scrim), centered status text "Removing object…", type.body.strong, text.primary, with a 32×32 skeleton-pulse square below.

Visual mockup of the Photoshop-workspace composition follows in mockup phase.

2.23 timeline (editor-class)

Multi-track, frame/sample-precise time axis. Theme-wise:

┌────────────────────────────────────────────────────────────────────┐
│ 00:00       00:30       01:00       01:30       02:00       02:30 │ ← ruler
├────────────────────────────────────────────────────────────────────┤
│ V1 ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ │ ← video track
├────────────────────────────────────────────────────────────────────┤
│ A1 ▁▁▂▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▁▁▁▂▃▄▅▆▇█▇▆▅▄▃▂▁▁▁▁▁▁▁▁▁▁ │ ← audio track
├────────────────────────────────────────────────────────────────────┤
│ ▲ playhead at 00:34                                                 │
└────────────────────────────────────────────────────────────────────┘
  • Ruler: 24px tall, type.caption text.secondary ticks, major ticks at full- second/full-minute intervals depending on zoom.
  • Track height: 48px video, 56px audio (waveform needs vertical room), 24px marker.
  • Track label column: 32px wide on the left, type.caption.strong text.secondary, centred vertically.
  • Track clip rendering: 1px border.default outline, accent.subtle fill for audio waveform background, bg.surface.sunken for video, with the source-media preview thumbnails inside.
  • Selected clip: 2px accent border, accent.subtle background tint.
  • Playhead: 2px vertical accent line spanning all tracks, with a 12×12 downward- pointing triangle at the top.

Visual mockup of multi-track editing follows in mockup phase.

2.24 vector-path (editor-class)

Pen-tool curves.

  • Path stroke: 2px accent when active, 1px text.primary when not.
  • Anchor points (when path is selected): 8×8 square, bg.surface.raised fill, 1px accent border. Selected anchor: 8×8, accent fill.
  • Control-handle lines: 1px dashed accent.subtle, with the handle endpoint rendered as a 6×6 circle, bg.surface.raised, 1px accent border.

Used inside canvas-region (as an overlay) or standalone (e.g. an EQ curve inside an audio-edit inspector form).


3. Composition Idiom Visuals

The five idioms from the Composition-Idiom Library, rendered in the Omadia theme. No visual mimicry of the era they reference — the idiom is a layout hint, not a skin. The Omadia theme renders all of them.

3.1 Norton-Commander-style

Two panes side-by-side, each holding a list, shared toolbar below.

╔═══════════════════════════════════════════════════════════════════╗
║ ┌────────────────────────┐  ┌────────────────────────────────┐    ║
║ │ Left pane              │  │ Right pane                     │    ║
║ │ ───────────────────────│  │ ───────────────────────────────│    ║
║ │ /home/user/projects    │  │ /home/user/projects/omadia-ui  │    ║
║ │                        │  │                                │    ║
║ │ ▸ omadia               │  │   CONCEPT.md          25.3 KB  │    ║
║ │ ▸ omadia-ui            │  │   README.md            1.2 KB  │    ║
║ │ ▸ tri-trading          │  │ ▸ docs/                        │    ║
║ │ ▸ archive              │  │   visual-spec.md      18.4 KB  │    ║
║ │                        │  │                                │    ║
║ └────────────────────────┘  └────────────────────────────────┘    ║
║                                                                    ║
║ ┌────────────────────────────────────────────────────────────┐    ║
║ │ [Copy]  [Move]  [Diff]  [Open]               [⌘K palette]  │    ║
║ └────────────────────────────────────────────────────────────┘    ║
╚═══════════════════════════════════════════════════════════════════╝
  • Two panes, side-by-side, equal width by default, resizable divider in the middle (drag-handle treatment from §2.17).
  • Each pane: title strip + list with type.mono.data for data-grid feel (file sizes, line counts).
  • Shared toolbar below: 40px default, ghost-variant action buttons left, primary action right.
  • Keyboard focus moves between panes via Tab; arrow keys move within active pane.
  • Density: typically compact (mono-leaning data grid).

What is not taken from Norton Commander: blue background, white-on-blue text, heavy box-drawing borders, function-key labels at the bottom. The agent expresses the layout, the theme renders it Omadia-style.

3.2 Wizard

container with step-tabs + form per step + toolbar (back/next).

┌─────────────────────────────────────────────────────────────────┐
│ Sales Proposal — AcmeInsure                                      │
│ ─────────────────────────────────────────────────────────────── │
│                                                                  │
│  ● Customer ─── ● Use Case ─── ○ Pricing ─── ○ Document          │
│                                                                  │
│ ─────────────────────────────────────────────────────────────── │
│                                                                  │
│ Customer name        ┌────────────────────────────┐              │
│                      │ AcmeInsure                 │              │
│                      └────────────────────────────┘              │
│                                                                  │
│ Contact email        ┌────────────────────────────┐              │
│                      │ contact@acmeinsure.com     │              │
│                      └────────────────────────────┘              │
│                                                                  │
│ Branch               (•) Insurance   ( ) Banking   ( ) Other     │
│                                                                  │
│ ─────────────────────────────────────────────────────────────── │
│                                                                  │
│ [ ← Back ]                                          [ Next → ]   │
└─────────────────────────────────────────────────────────────────┘
  • Steps: filled accent circle (●) for completed, accent ring (○) for current, border-subtle ring for upcoming. Connecting lines: border.subtle, current / completed segments switch to accent.
  • Step labels under each circle: type.caption text.secondary for inactive, type.caption.strong text.primary for current/completed.
  • Form: label-on-left layout (40/60 split), as inspector-mode in §2.13.
  • Back / Next: secondary / primary, right-aligned for forward motion.
  • Next-disabled state until required fields valid (rendered as button-disabled in §2.7).

3.3 Spotlight

Centered input + list of hits.

                  ┌────────────────────────────────────────────┐
                  │ 🔍  Search projects, files, commands…      │
                  └────────────────────────────────────────────┘
                  ┌────────────────────────────────────────────┐
                  │ ► CONCEPT.md                  /omadia-ui   │
                  │   visual-spec.md              /omadia-ui   │
                  │   architecture-3tier.svg      /omadia-ui   │
                  │ ─────────────────────────────────────────  │
                  │   Run: omadia start                        │
                  │   Run: vercel deploy --prod                │
                  └────────────────────────────────────────────┘
  • Centered in viewport, max 640px wide.
  • Input: 48px tall, type.heading.2 text size, 1px border.default, leading search icon at icon.lg text.tertiary.
  • Results list: directly below, no gap, same width, bg.surface.raised, elev.popover, radius.md.
  • First result auto-focused (►). Arrow up/down moves focus, Enter triggers.
  • Section dividers: 1px border.subtle with optional caption.strong label on the left side (text.tertiary).
  • Right-side hint: type.caption text.tertiary (path, type, age).

This idiom is Omadia UI's command palette (⌘K).

3.4 Dashboard

grid of container with chart, status, KPI-text.

┌──────────────────────────────────────────────────────────────────────┐
│ Monthly Overview                                       Apr 2026  ▾   │
│ ──────────────────────────────────────────────────────────────────── │
│                                                                       │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────────────────┐│
│ │ Open Tickets   │ │ Hours Budget   │ │ Weekly Trend                ││
│ │                │ │ Remaining      │ │                             ││
│ │     127        │ │     342h       │ │     ▁▂▃▅▆▇█▇▆▅▃▂            ││
│ │ ▲ 12 vs Mar    │ │ ▼ 8% vs Mar    │ │                             ││
│ └────────────────┘ └────────────────┘ └────────────────────────────┘│
│                                                                       │
│ ┌──────────────────────────────────────┐ ┌─────────────────────────┐ │
│ │ Owners under budget                  │ │ Recent activity         │ │
│ │ ─────────────────────────────────────│ │ ────────────────────────│ │
│ │  Anna Schmidt           5.0h         │ │ 14:23 — pdf generated   │ │
│ │  Bernd Lutz             7.5h         │ │ 13:51 — sub-agent done  │ │
│ │  ...                                  │ │ ...                     │ │
│ └──────────────────────────────────────┘ └─────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
  • Outer container: space.5 padding, optional style: "spacious" for breathing room.
  • KPI cards: small container with border: true, radius.md, padding space.5. Top label type.caption.strong text.secondary, big value type.displaytext.primary, delta line below at type.caption text.secondary with a small ▲/▼ glyph in accent (up) or state.warning.fg (down) — note this is the single documented use of a non-accent semantic colour in a "status" context, and it is text-only, not a pill.
  • Charts inside the same containers, no decorative chrome.
  • Grid: 4-column CSS grid, gap space.5, KPI cards span 1 col, trend chart span 2, table widgets span 2.

3.5 Photoshop-workspace

canvas-region centre, toolbar left, inspector (form with context-binding) right, tree (layer stack) bottom-right.

╔═══════════════════════════════════════════════════════════════════════════╗
║┌──┐ ┌─────────────────────────────────────┐ ┌──────────────────────────┐ ║
║│⬛│ │                                     │ │ INSPECTOR                │ ║
║│⊙ │ │                                     │ │ ──────────────────────── │ ║
║│✦ │ │                                     │ │ Tool      Brush          │ ║
║│✎ │ │                                     │ │ Size      ────●────  18  │ ║
║│  │ │       active canvas region          │ │ Hardness  ────●────  60%  │ ║
║│⤬ │ │       (image being edited)          │ │ Opacity   ────●────  85%  │ ║
║│  │ │                                     │ │ Colour    ████ #0F7AB8   │ ║
║│✂ │ │                                     │ │ Flow      ─●──────   24% │ ║
║│  │ │                                     │ └──────────────────────────┘ ║
║│■ │ │                                     │ ┌──────────────────────────┐ ║
║│  │ │                                     │ │ LAYERS                   │ ║
║│⤡ │ │                                     │ │ ──────────────────────── │ ║
║│⤢ │ │                                     │ │ 👁 ▸ Adjustments         │ ║
║│  │ │                                     │ │ 👁    Curves             │ ║
║│  │ └─────────────────────────────────────┘ │ 👁    Levels             │ ║
║│  │                                         │ 👁 ► Background          │ ║
║│  │ ┌─────────────────────────────────────┐ │                          │ ║
║│  │ │ ⏵  ┃━━━━━━━━━━●━━━━━━━━━━━━━━━━ 32% │ │                          │ ║
║│  │ │   undo · redo · zoom in/out · fit   │ │                          │ ║
║└──┘ └─────────────────────────────────────┘ └──────────────────────────┘ ║
╚═══════════════════════════════════════════════════════════════════════════╝
  • Left toolbar: vertical, 48px wide, button-square 40×40, bg.surface.sunken, border.subtle right edge. Active tool button: accent.subtle background, 1px accent border.
  • Inspector (right top): form in context-binding mode (label-left layout). Sliders use the scrubber visual from §2.21 (4px track, 12px knob).
  • Layer stack (right bottom): tree with layer trait. Eye-icon toggles visibility.
  • Bottom toolbar: zoom slider + undo/redo, ghost buttons.
  • Canvas region: see §2.22; sharp corners, sunken background.

The colour swatch in the inspector (#0F7AB8 shown above) is illustrative — when the user picks a custom colour, that custom colour is shown verbatim. This is the only place in the UI where a non-theme colour is rendered, and it is rendered because it is data, not chrome.


4. Animation and Transition Language

4.1 Patch-apply

When a surface_patch arrives and rewrites part of the tree:

  • The replaced subtree fades out (opacity: 0) over motion.quick.
  • The new subtree fades in over motion.quick.
  • Cross-fade if both old and new fit the same DOM slot; otherwise sequential out-then-in.
  • The new subtree gets a patch-highlight overlay: a radius.sm rectangle matching the new content's bounds, filled with accent.subtle, fading from opacity: 1 to opacity: 0 over motion.smooth * 4 (~800ms). This is the visual signal that "the canvas just grew here".

Patches affecting a single value (table cell update, status text change): no fade-out, just the highlight overlay on the changed cell.

Snapshots (surface_snapshot) get a full-canvas crossfade over motion.smooth with easing.emphasis.

Rationale — fade-in chosen over slide-down. Three options considered:

  1. Slide-down (new content slides in from above the patch target). Risk: in dense data UIs, sliding visually pushes adjacent content around; on a 60-row table this is more disorienting than helpful.
  2. Pulse (new content briefly scales 1.05 → 1.00). Risk: scale animations look toy-like in editor workloads; collides with the "data is the protagonist" value.
  3. Selected — fade + highlight. Quiet, doesn't move layout, signals "this is new" via a temporary accent wash, reduced-motion-safe (skip the fade, drop the highlight, jump to final state).

4.2 Skeleton states

See §1.7 — pulse animation. The skeleton fills the bounds of the missing primitive with state.loading, animates the pulse, and never shows a spinner. Reduced-motion disables the pulse but keeps the fill.

4.3 Modal appearance

  • Scrim (bg.modal.overlay): fade in over motion.smooth with easing.standard.
  • Modal pane: opacity 0 → 1 + scale 0.97 → 1.0 over motion.smooth with easing.emphasis.
  • Modal dismiss: reverse, motion.quick.

4.4 Selection / focus feedback

  • Focus ring (border.focus 2px): instant on focus change. No fade — focus must be visible the moment the user tabs to it.
  • Selection (list/table/tree row): accent.subtle background fades in over motion.quick. Multi-select cumulative selection: each newly-selected row fades in over motion.quick.
  • Deselect: fade out over motion.quick.

4.5 Hover

  • Background tint changes fade over motion.quick.
  • Cursor change is instant.

4.6 Canvas-activate transition (Spaces switch)

When user switches canvases (CONCEPT.md §"Multiple Canvases"):

  • Outgoing canvas: fade out + 4px horizontal slide (depending on switch direction) over motion.deliberate.
  • Incoming canvas: fade in + 4px horizontal slide-in over motion.deliberate, starts 60ms after outgoing begins.
  • Reduced motion: instant swap, no fade.

This is the only use of motion.deliberate. Spaces-switch is meant to feel heavier than an in-canvas patch — the user has changed context, and the motion acknowledges that.

4.7 Drag-in-flight

  • Ghost preview: 50% opacity copy of the source primitive, elev.drag, follows cursor. Z-index sits above all canvas content.
  • Drop targets: 2px dashed accent border fades in (motion.quick) when the ghost enters the target's bounding box; fades out when it exits.
  • Drop: ghost fades out over motion.quick, real content appears in new location.

5. Edge Cases and Anti-Patterns

5.1 Empty canvas (first launch, no prior session)

  • No "Welcome to Omadia!" splash. No branded empty state. No tutorial overlay.
  • The canvas renders bg.canvas, nothing else.
  • A status primitive in the lower-left corner, type.caption text.tertiary, reads: Canvas ready. ⌘K to start.
  • That is the entire empty state. Anything more is an anti-pattern in v1.

Rationale. CONCEPT.md is explicit: the canvas is "the agent's blank page". A welcome screen authored by a designer breaks the model — the user should immediately feel that this surface waits for them. Power users learn ⌘K once, forever; new users discover it through the status hint or through the agent's first response after they type into the channel-side chat.

5.2 Loading > 300ms

  • 0–300ms: nothing rendered. Render the eventual primitive immediately if the payload arrives in time.
  • 300ms+: skeleton renders for the expected primitive shape.
  • 3s+: skeleton continues; status indicator (status primitive with loading trait) appears below or beside the skeleton, agent-authored caption explaining what's happening.
  • 10s+: same skeleton; status caption becomes more specific ("Still fetching Q1 invoices — large dataset, ~30s expected").

Skeletons never time out into a spinner. They time out into a useful error if the operation actually fails (§5.3).

5.3 Errors

Three error scopes:

  1. Primitive-scoped error (a single primitive failed to render or fetch its dataRef):

    • 1px state.error.edge border on the primitive.
    • Inline message below or inside, state.error.fg, type.caption.
    • No badge, no pill, no toast.
  2. Field error (form field validation):

    • 1px state.error.edge border around the input.
    • Helper-text slot under the input becomes the error message, state.error.fg, type.caption.
  3. Canvas-scoped error (a sub-agent failed, a Tier-3 tool errored, dataRef denied):

    • A status primitive at the top of the affected container, leading alert-triangle icon at icon.sm state.error.fg, message at type.bodystate.error.fg.
    • Optional inline retry action: button ghost-variant + text "Retry".

Toasts (transient floating notifications): not used. The canvas is the surface of record; transient toasts would create a parallel notification stream that the agent didn't author. If the agent needs to surface an error, it adds it to the tree (as a primitive, in the right scope) — and the user sees it in context.

5.4 Confirmation modals

CONCEPT.md § "External-effect action confirmation contract" defines the wire shape. The visual is:

                ╔════════════════════════════════════════════════╗
                ║                                                ║
                ║  Confirm send                                  ║   ← heading.2
                ║                                                ║
                ║  Send proposal PDF to contact@acmeinsure.com?  ║   ← body
                ║  This email cannot be unsent.                  ║   ← body, text.secondary
                ║                                                ║
                ║                                                ║
                ║                       [ Cancel ]  [ Send → ]   ║   ← secondary + primary
                ╚════════════════════════════════════════════════╝

       (scrim covers everything else: bg.modal.overlay)
  • Modal pane: bg.modal.surface, radius.lg, elev.modal, max 480px wide.
  • Padding: space.6 (spacious — modal is a moment of focus).
  • Heading: type.heading.2, text.primary.
  • Body: type.body, text.primary for the main message, text.secondary for the irreversibility caveat.
  • Toolbar: right-aligned, Cancel (secondary) + primary action. Primary action label uses the verb of the action, not "OK" or "Confirm".
  • Keyboard: Esc cancels, Enter triggers the primary action. Focus on first open is the primary action unless the action is destructive (danger variant), in which case focus opens on Cancel — a small but deliberate friction.

For danger confirmations (file delete, payment, irreversible publish), the primary button uses the danger variant (1px state.error.edge border, text in state.error.fg, on hover bg → bg.surface.sunken). No filled red button.

5.5 Anti-patterns to call out by name

The implementer must not:

  • Add coloured status pills ("OK", "BLOCKED", "OVERDUE") in any colour. The word in body text, in text.primary, is what carries meaning. Where emphasis is needed, use type.body.strong or row tint via accent.subtle.
  • Add emoji glyphs as decorative chrome. Emoji that the agent emits as content (Walkthrough 1 prose "🎉") are content and pass through verbatim. Emoji that an implementer adds to button labels or empty-state hints are forbidden.
  • Add toasts, snackbars, or any floating non-modal notification surface.
  • Add circular spinners anywhere except the documented button-in-flight exception (§2.7) and the loading: "spinner" trait on canvas-region (§2.22), which renders as a 32×32 skeleton-pulse square, not an animating ring.
  • Add gradients beyond the skeleton-pulse gradient. No accent-to-purple gradient buttons, no glassmorphism, no neumorphism.
  • Add drop shadows to flat content (cards, list items, panels). Shadows are reserved for temporally elevated surfaces (§1.6).
  • Add a branded splash or empty-state illustration.
  • Add multiple accent colours. There is exactly one accent slot.

6. Accessibility floor

This is not a full accessibility spec; it is the floor below which the visual choices already documented would themselves break a11y guarantees.

  • Contrast ratios (verified against WCAG 2.2 AA at body-text size):
    • text.primary on bg.canvas (both modes): ≥ 7.0:1 (AAA).
    • text.secondary on bg.canvas: ≥ 4.5:1 (AA).
    • text.tertiary on bg.canvas: ≥ 3.5:1 (AA Large only — used only on type at 14px+).
    • text.inverse on accent: ≥ 4.5:1 in both modes.
    • accent on bg.canvas: ≥ 3:1 (AA non-text — focus rings, icons).
  • Focus rings: 2px solid border.focus, 2px offset (or inset where noted). Never just a colour change without a ring.
  • Hit targets: minimum 32×32 for any clickable affordance (default button size). 24px buttons exist only inside high-density toolbars where 32px would break the layout; those toolbars are keyboard-accessible parallel paths.
  • Motion: every animation respects prefers-reduced-motion: reduce (§1.7).
  • Colour as sole signal: forbidden. Every state communicated through colour also carries a text label, an icon, or both.
  • Keyboard reach: every interactive primitive must be reachable via Tab and operable via Enter/Space. Composite primitives (table, tree, list) must support arrow-key navigation within them.

7. What is explicitly NOT specified in this document

Out of scopeWhere it belongs
Pixel-genau editor-workspace mockup (Photoshop idiom)Mockup phase + Tier-1 spike
canvas-region / timeline / media pixel visualsMockup phase
Brand identity — logo, wordmark, app icon, nameSeparate brand-work track
Onboarding / first-run flowSeparate UX phase
Settings / Preferences screenDoes not exist by design — user prefs are conversational
Marketing site visualsSeparate marketing-design track
Email or notification visuals (transactional)Out of scope — Omadia UI has no email surface
Cross-platform native-control divergenceImplementation choice during Tier-1 spike
Print stylesheetsNot a workload for v1

8. Implementation contract

When implementers consume this spec:

  • Token names from §1 are authoritative; renderer code references tokens by semantic name only.
  • Per-primitive visual tables (§2) are authoritative for default, hover, focus, active, disabled, loading, error states. Variants are restricted to those listed; new variants require a spec amendment.
  • Composition idioms (§3) are normative for the wireframe relationships (which primitive is where, which gets which density). The Skill remains free to vary primitive choice within the idiom (e.g. swapping list for table if data is uniform), but the layout language is fixed.
  • Motion (§4) is normative. Implementers may not invent new transitions for unlisted situations without an amendment.
  • Anti-patterns (§5.5) are blockers — code that reintroduces them must not ship.

9. Open questions for review

These are explicitly flagged for Codex review rounds — areas where the spec made a call but a reviewer might land elsewhere with good arguments.

  1. Accent choice. Petrol/steel-blue at 235°. Alternatives (indigo, copper, ochre) listed in §1.1 rationale. Worth a second pass before lock-in.
  2. Inter vs. system sans. Inter on all platforms vs. SF Pro on macOS / Inter elsewhere (two-typeface compromise). The current choice is Inter-everywhere for consistency; native-purist reviewers may push back.
  3. JetBrains Mono vs. SF Mono / IBM Plex Mono. Trade-offs in §1.3.
  4. 4pt vs. 8pt grid. 4pt was chosen for editor-workload density. 8pt would be cleaner for non-editor workloads. A hybrid (8pt grid with 4pt half-stops for editor only) was considered and rejected because it complicates the token set.
  5. Single-spinner exception in §2.7. The marquee-dots compromise for button- in-flight may strike reviewers as too clever; the alternative is silence (disabled button with label change only).
  6. Toasts forbidden — too strict? A reviewer might argue background-Tier-3 completions (Walkthrough 4) deserve a transient surface that doesn't displace canvas content. Current answer: those are status primitives in a designated "activity" pane, not toasts. Worth re-examining.
  7. Patch-highlight overlay (§4.1). ~800ms accent-subtle wash may be too long for high-frequency patch streams (typing-speed canvas updates in Walkthrough 4 step 8: live notes pane). A patch-rate-aware shorter highlight, or no highlight on rapid streams, may be needed.
  8. Empty canvas hint (§5.1). The "Canvas ready. ⌘K to start." caption is the single concession to discoverability. A reviewer might reasonably argue for no caption at all. Counter-argument: first-launch UX without any affordance leaves new users staring.

10. Changelog

  • v0.1 — first draft, written against CONCEPT.md v0.7 and walkthroughs.md. Defines tokens (light + dark), 24 per-primitive visuals, 5 composition idioms, motion language, accessibility floor, anti-patterns. 8 open questions flagged for Codex review.

Lume Visual Specification · reconstructed from git history