From a0c21b058229e0ef392f8498d876b4a2e86c035b Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 12:48:52 +0200 Subject: [PATCH] docs(13): UI design contract for design system foundation phase Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../13-design-system-foundation/13-UI-SPEC.md | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 .planning/phases/13-design-system-foundation/13-UI-SPEC.md diff --git a/.planning/phases/13-design-system-foundation/13-UI-SPEC.md b/.planning/phases/13-design-system-foundation/13-UI-SPEC.md new file mode 100644 index 0000000..8800596 --- /dev/null +++ b/.planning/phases/13-design-system-foundation/13-UI-SPEC.md @@ -0,0 +1,660 @@ +--- +phase: 13 +slug: design-system-foundation +status: draft +shadcn_initialized: false +preset: none +created: 2026-05-16 +--- + +# Phase 13 — UI Design Contract + +> Visual and interaction contract for the Design System Foundation phase. +> Generated by gsd-ui-researcher, verified by gsd-ui-checker. +> +> **Stack note:** This is a Go + HTMX project. There is no React, no shadcn, no +> node-based component library. The design system is implemented as: +> - CSS custom properties in `backend/internal/web/ui/base.css` +> - Component CSS files under `backend/internal/web/ui/` +> - Go templ components consumed by HTMX-driven server-rendered pages +> - Tailwind v4 utility classes alongside component CSS (no @apply) + +--- + +## Design System + +| Property | Value | Source | +|----------|-------|--------| +| Tool | none (Go + templ, not shadcn) | Project constraint | +| Preset | not applicable | n/a | +| Component library | Go templ components in `backend/internal/web/ui/` | Existing codebase | +| Icon library | Inline SVG via `UIIcon(name string)` templ helper | go-backend pattern | +| Font | `ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif` | go-backend `base.css` | + +**Registry safety gate:** not applicable (no npm registry consumption). + +--- + +## Token Vocabulary + +All tokens are CSS custom properties declared in `backend/internal/web/ui/base.css`. +Phase 13 ports the full 223-line vocabulary from `go-backend/internal/web/ui/base.css` verbatim. +Decision D-T01 and D-T02 lock this: no adjustments before Phase 13 lands. + +### Color Tokens (key values — from go-backend base.css) + +| Group | Token | Value | +|-------|-------|-------| +| Brand | `--color-brand-primary` | `#804eec` | +| Brand | `--color-brand-primary-hover` | `#6d28d9` | +| Brand | `--color-brand-primary-active` | `#5b21b6` | +| Brand | `--color-brand-secondary` | `#a855f7` | +| Brand | `--color-brand-accent` | `#3b82f6` | +| Brand | `--color-brand-ink` | `#1e1b2e` | +| Text | `--color-text-primary` | `hsl(0 0% 9%)` | +| Text | `--color-text-secondary` | `#475467` | +| Text | `--color-text-muted` | `hsl(0 0% 43.5%)` | +| Text | `--color-text-faint` | `#9ca3af` | +| Text | `--color-text-inverse` | `#ffffff` | +| Text | `--color-text-brand` | `#804eec` | +| Text | `--color-text-disabled` | `#667085` | +| Surface | `--color-surface-page` | `hsl(0 0% 100%)` | +| Surface | `--color-surface-default` | `#ffffff` | +| Surface | `--color-surface-card` | `rgba(255,255,255,0.8)` | +| Surface | `--color-surface-subtle` | `hsl(0 0% 96.1%)` | +| Surface | `--color-surface-muted` | `#f3f4f6` | +| Surface | `--color-surface-brand-soft` | `#ede9fe` | +| Border | `--color-border-default` | `hsl(0 0% 90.9%)` | +| Border | `--color-border-strong` | `#d1d5db` | +| Border | `--color-border-subtle` | `#d0d5dd` | +| Focus | `--color-focus-ring` | `rgba(124,58,237,0.2)` | +| Focus | `--color-focus-ring-strong` | `rgba(139,92,246,0.16)` | +| Danger | `--color-status-danger-strong` | `#dc2626` | +| Danger | `--color-status-danger-foreground` | `#dc2626` | +| Success | `--color-status-success-strong` | `#16a34a` | +| Warning | `--color-status-warning-strong` | `#db9729` | + +Full vocabulary: 223 custom properties — see `go-backend/internal/web/ui/base.css` for the complete list. Port verbatim; no value changes in Phase 13. + +--- + +## Spacing Scale + +Phase 13 defines spacing both as CSS custom-property-backed SpacingStep utility components and +as raw CSS values used inside component rules. All values are as declared in `go-backend/internal/web/ui/spacing.css`. + +| Token | CSS Value | rem | SpacingStep const | +|-------|-----------|-----|-------------------| +| xs | 4px | 0.25rem | `SpacingStepXS` | +| sm | 8px | 0.5rem | `SpacingStepSM` | +| md | 12px | 0.75rem | `SpacingStepMD` | +| lg | 16px | 1rem | `SpacingStepLG` | +| xl | 24px | 1.5rem | `SpacingStepXL` | + +**Note:** The spacing component (`SpaceX` / `SpaceY`) uses this non-standard scale (xs=4, md=12, xl=24) +because it is a port of the go-backend spacing utility. This scale serves the spacing *component* only. +Layout-level spacing in the catalog fake-shell uses Tailwind utilities directly. + +Touch target exceptions: buttons and icon-buttons enforce `min-height: 44px` and icon-buttons enforce +`min-width: 44px` for WCAG 2.5.5 compliance. These override the spacing scale where needed. + +--- + +## Typography + +Sourced from `go-backend/internal/web/ui/base.css` body declaration and component CSS measurements. + +| Role | Size | Weight | Line Height | Token / Usage | +|------|------|--------|-------------|---------------| +| Body | 16px (1rem) | 400 regular | 1.5 | Default prose, descriptions | +| Label / form | 15.2px (0.95rem) | 600 semibold | 1.4 | Form labels, button labels | +| Small / hint | 14px (0.875rem) | 400 regular | 1.4 | Form hints, error messages, badge text, table headers | +| Heading | 18px (1.125rem) | 700 bold | 1.2 | Modal titles, empty-state titles, section headings | + +**Font stack (declared in base.css body rule):** +``` +ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif +``` + +No custom web font is introduced in Phase 13. The system stack is the final choice. + +--- + +## Color Contract + +60/30/10 split expressed in terms of the token vocabulary: + +| Role | Token / Value | Usage | +|------|---------------|-------| +| Dominant (60%) | `--color-surface-page` (#ffffff) | Page background, form field backgrounds | +| Secondary (30%) | `--color-surface-muted` (#f3f4f6) | Card backgrounds, icon-button hover states, empty-state icon container, select chips | +| Accent (10%) | `--color-brand-primary` (#804eec) | Primary button background, focus rings, brand text, selected option highlight | +| Destructive | `--color-status-danger-strong` (#dc2626) | Danger button variant only | + +**Accent reserved for:** +- Solid default button (`ui-button-solid-default-*`) +- Focus ring on all interactive elements (`--color-focus-ring`) +- Brand text (`--color-text-brand`) +- Soft default button background (`--color-surface-brand-soft`) + +**Accent is NOT used for:** neutral buttons, card borders, table rows, form field borders at rest, badge backgrounds (badges use status-color tokens). + +--- + +## Component Inventory + +All 11 component types must be present as both a templ component and a CSS file. +The catalog page must render every variant listed here. + +### Button (DS-02) + +Class pattern: `ui-button ui-button-{tone}-{variant}-{size}` + +| Axis | Values | CSS classes | +|------|--------|-------------| +| Variant | default, neutral, warning, success, danger, **ghost** (add) | `ui-button-default`, `ui-button-neutral`, `ui-button-warning`, `ui-button-success`, `ui-button-danger`, `ui-button-ghost` | +| Tone | solid, soft | `ui-button-solid`, `ui-button-soft` | +| Size | sm, md, lg | `ui-button-sm`, `ui-button-md`, `ui-button-lg` | + +**Ghost variant:** `ButtonVariantGhost` added to `variants.go`. Class combo `ui-button-ghost` renders with transparent background + brand-colored text, hover shows `--color-surface-brand-soft`. CSS mirrors go-backend ghost rules. + +Props struct (go-backend parity): +```go +type ButtonProps struct { + Label string + Variant ButtonVariant + Tone ButtonTone + Size Size + Type string + Icon string // icon name for UIIcon helper + Disabled bool // add if missing in backend + Attrs templ.Attributes +} +``` + +Interaction states: default → hover (background shift) → active (darker) → focus-visible (3px focus ring `--color-focus-ring`) → disabled (opacity 0.5, cursor not-allowed). + +### Badge (DS-05) + +Class pattern: `ui-badge ui-badge-{variant}` + +| Variant | Background token | Border token | Text token | +|---------|-----------------|--------------|------------| +| info | `--color-status-info-soft-bg` | `--color-status-info-soft-border` | `--color-status-info-foreground` | +| warning | `--color-status-warning-soft-bg` | `--color-status-warning-soft-border` | `--color-status-warning-foreground` | +| success | `--color-status-success-soft-bg` | `--color-status-success-soft-border` | `--color-status-success-foreground` | +| danger | `--color-status-danger-soft-bg` | `--color-status-danger-soft-border` | `--color-status-danger-foreground` | +| **primary** (add) | `--color-surface-brand-soft` | `--color-brand-primary` at 30% opacity | `--color-text-brand` | + +**Primary variant:** `BadgeVariantPrimary` added to `variants.go`. Uses brand-purple tones to signal "active" or "primary" status (distinct from info blue). + +Props struct (go-backend parity): +```go +type BadgeProps struct { + Label string + Variant BadgeVariant +} +``` + +### Card (DS-04) + +Class: `ui-card` wrapping `ui-card-header`, `ui-card-body`, `ui-card-footer` + +- Border radius: 1rem +- Border: 1px solid `--color-border-default` +- Shadow: `--shadow-surface-md` +- Section padding: `1.25rem 1.5rem` +- Header/footer have a 1px divider at `--color-border-default` + +Props struct (go-backend parity): +```go +type CardProps struct { + Attrs templ.Attributes +} +``` +Header, Body, Footer are separate templ components taking `templ.Component` children. + +### Input (DS-03) + +Class: `ui-input` + +- Border radius: 0.75rem +- Min-height: 44px (touch target) +- Width: 100% +- Placeholder color: `--color-text-faint` +- Focus: border-color `--color-brand-focus`, box-shadow `0 0 0 3px --color-focus-ring-strong`, outline none + +Props struct: +```go +type InputProps struct { + Name string + ID string + Type string // defaults to "text" + Placeholder string + Value string + Disabled bool + Required bool + Attrs templ.Attributes +} +``` + +### Textarea (DS-03) + +Class: `ui-textarea` + +- Border radius: 0.75rem +- Min-height: 7rem +- Width: 100%, resize: vertical +- Same placeholder and focus behavior as input + +Props struct: +```go +type TextareaProps struct { + Name string + ID string + Placeholder string + Value string + Rows int + Disabled bool + Required bool + Attrs templ.Attributes +} +``` + +### Select (DS-03) + +Class: `ui-select` wrapping `ui-select-control`, `ui-select-menu`, `ui-select-option` + +- Control: min-height 44px, border-radius 0.75rem, chevron arrow that rotates 180deg when `is-open` +- Menu: absolute positioned, max-height 16rem, overflow-y auto, border-radius 0.9rem +- Option selected state: `is-selected` class → `--color-status-info-soft-bg` background + `--color-text-brand` text + +Props struct: +```go +type SelectOption struct { + Value string + Label string + Selected bool + Disabled bool +} + +type SelectProps struct { + Name string + ID string + Options []SelectOption + Disabled bool + Attrs templ.Attributes +} +``` + +### Modal (DS-06) + +Class: `ui-modal-backdrop` → `ui-modal-panel` → `ui-modal-header` / `ui-modal-body` / `ui-modal-actions` + +- Backdrop: `position: fixed`, `inset: 0`, `background: --overlay-backdrop-default`, flex center +- Panel: max-width 32rem, border-radius 1rem, shadow `--shadow-surface-lg` +- Header: `font-size: 1.125rem`, `font-weight: 700`, bottom border divider +- Actions: flex row, justify end, gap 0.75rem, top border divider +- HTMX pattern: backdrop rendered server-side into `#modal-slot`; close button sets `hx-delete` or swaps empty fragment + +Props struct: +```go +type ModalProps struct { + Title string + Attrs templ.Attributes +} +``` +Body and Actions are passed as `templ.Component` children. + +### Empty State (DS-07) + +Class: `ui-empty-state` wrapping `ui-empty-state-icon`, `ui-empty-state-title`, `ui-empty-state-description` + +- Border: 1px dashed `--color-border-subtle` +- Border radius: 1rem +- Icon container: 4rem × 4rem circle, `--color-surface-muted` background, `--color-text-faint` icon +- Icon SVG: 2rem × 2rem +- Title: `font-size: 1.125rem`, `font-weight: 700` +- Description: max-width 32rem, `--color-text-muted` + +Props struct: +```go +type EmptyStateProps struct { + Icon string + Title string + Description string + Attrs templ.Attributes +} +``` +CTA button (optional) passed as `templ.Component` child. + +### Table (DS-08) + +Class: `ui-table-shell` → `ui-table` + +- Shell: `overflow-x: auto`, `width: 100%` +- Table: `border-collapse: collapse`, `min-width: 100%` +- Header cells: 14px, weight 600, `--color-text-secondary`, uppercase tracking (Tailwind utilities) +- Body rows: 16px, `--color-text-primary`, hover `--color-surface-subtle` +- Borders: 1px `--color-border-default` on row bottoms + +Props struct: +```go +type TableProps struct { + Attrs templ.Attributes +} +``` +Head, Body, Row, Cell, HeaderCell are separate templ sub-components. + +### Icon Button (DS-09) + +Class patterns: +- Solid: `ui-icon-button ui-icon-button-solid ui-icon-button-{variant}` +- Ghost: `borderless-icon-button ui-icon-button-ghost ui-icon-button-{variant}` + +- Min-height: 44px, min-width: 44px (touch targets) +- Variants: neutral, warning, success, danger +- Tones: solid, ghost +- Ghost: transparent background, no border, SVG 1rem × 1rem + +Props struct: +```go +type IconButtonProps struct { + Icon string + Variant IconButtonVariant + Tone IconButtonTone + Type string + Label string // aria-label + Attrs templ.Attributes +} +``` + +### Form Field (DS-03 wrapper) + +Class: `ui-form-field` wrapping `ui-form-label`, input/textarea/select, `ui-form-hint`, `ui-form-error` + +- Label: `font-size: 0.95rem`, `font-weight: 600`, `--color-text-primary` +- Hint: `font-size: 0.875rem`, `--color-text-muted` +- Error: `font-size: 0.875rem`, `--color-status-danger-foreground` +- Grid layout with `gap: 0.5rem` + +Props struct: +```go +type FormFieldProps struct { + Label string + Hint string + Error string + Required bool + ForID string +} +``` +Input/textarea/select passed as `templ.Component` child. + +--- + +## Spacing Component (utility — not layout) + +The `SpaceX` / `SpaceY` templ components render sized whitespace blocks for vertical/horizontal rhythm. +They are distinct from the CSS spacing scale used in layout. + +| Step | Width/Height | +|------|-------------| +| xs | 0.25rem (4px) | +| sm | 0.5rem (8px) | +| md | 0.75rem (12px) | +| lg | 1rem (16px) | +| xl | 1.5rem (24px) | + +--- + +## Catalog Page Contract + +**Route:** `GET /ui-catalog` +**Build gate:** `//go:build catalog` — compiled only with `-tags catalog` +**Template location:** `backend/internal/web/ui/catalog/catalog.templ` + +### Layout + +``` ++--sidebar (240px, fixed)--+--main content (fluid)--+ +| [Brand logo] | [Section heading] | +| #buttons | [Component grid] | +| #badges | [templ call annotation] | +| #cards | ...repeated per section | +| #inputs | | +| #textarea | | +| #select | | +| #modal | | +| #empty-state | | +| #table | | +| #icon-button | | +| #form-field | | ++--------------------------+-------------------------+ +``` + +The fake shell uses hand-written Tailwind utility classes (flex, min-h-screen, w-60, etc.) — no import of the real `app.css` which is deferred to Phases 14–17. + +### Section Structure + +Each section follows this pattern: + +``` +## Button — DS-02 + +[Rendered variant grid] + +Each cell: + [Rendered component] + ───────────────────── + ui.Button(ui.ButtonProps{ + Label: "Save", + Variant: ui.ButtonVariantDefault, + Tone: ui.ButtonToneSolid, + Size: ui.SizeMD, + }) +``` + +Section order in sidebar: alphabetical by component name (badge, button, card, empty-state, form-field, icon-button, input, modal, select, table, textarea). + +Each section header includes the DS requirement number: `Button — DS-02`. + +### Static only + +No JavaScript on the catalog page. All variants pre-rendered at page load. Modal section renders an open modal panel (without the backdrop toggle) so all states are visible without interaction. + +--- + +## Copywriting Contract + +This phase is infrastructure (CSS tokens + component library), not a user-facing feature. +The only user-visible surface is the dev-only catalog page. + +| Element | Copy | +|---------|------| +| Catalog page title | `Component Catalog` | +| Catalog nav heading | `Components` | +| Each section subheading | `{ComponentName} — {DS-req}` (e.g. `Button — DS-02`) | +| Empty state demo title | `Nothing here yet` | +| Empty state demo body | `Add your first item to get started.` | +| Empty state demo CTA | `Add item` | +| Table demo empty row | `No data to display` | +| Form error demo | `This field is required.` | +| Form hint demo | `Enter a value between 1 and 100.` | +| Modal demo title | `Confirm action` | + +--- + +## Go-Side API Contract + +### Variants to add in `backend/internal/web/ui/variants.go` + +```go +// Add to ButtonVariant enum: +ButtonVariantGhost ButtonVariant = "ghost" + +// Add new BadgeVariantPrimary: +BadgeVariantPrimary BadgeVariant = "primary" +``` + +`NormalizedButtonVariant` and `NormalizedBadgeVariant` must include the new cases. + +### Class generation functions to add/update + +```go +// ButtonClass already exists — update normalizedButtonVariant to include Ghost +// BadgeClass already exists — update normalizedBadgeVariant to include Primary + +// Add for new component types: +func CardClass() string +func InputClass(disabled bool) string +func TextareaClass(disabled bool) string +func ModalClass() string +func EmptyStateClass() string +func TableClass() string +func FormFieldClass() string +func IconButtonClass(variant IconButtonVariant, tone IconButtonTone) string // already in go-backend +func SpaceXClass(step SpacingStep) string // already in go-backend +func SpaceYClass(step SpacingStep) string // already in go-backend +``` + +### Test coverage (`backend/internal/web/ui/ui_test.go`) + +At minimum one smoke test per component type verifying class string output: + +| Component | Test function | Assertion | +|-----------|--------------|-----------| +| Button | `TestButtonClass` (extend) | Ghost variant → contains `ui-button-ghost` | +| Badge | `TestBadgeClass` (extend) | Primary variant → contains `ui-badge-primary` | +| Card | `TestCardClass` | Returns `ui-card` | +| Input | `TestInputClass` | Returns `ui-input` | +| Textarea | `TestTextareaClass` | Returns `ui-textarea` | +| Select | `TestSelectClass` | Returns `ui-select-control` | +| Modal | `TestModalClass` | Returns `ui-modal-panel` | +| EmptyState | `TestEmptyStateClass` | Returns `ui-empty-state` | +| Table | `TestTableClass` | Returns `ui-table` | +| IconButton | `TestIconButtonClass` | Ghost neutral → contains `borderless-icon-button` | +| FormField | `TestFormFieldClass` | Returns `ui-form-field` | + +--- + +## CSS File Manifest + +After Phase 13, `backend/tailwind.input.css` imports: + +```css +@import "tailwindcss"; + +@source "./templates/**/*.templ"; +@source "./internal/web/**/*.templ"; +@source "./internal/web/**/*.go"; + +@import "./internal/web/ui/base.css"; +@import "./internal/web/ui/button.css"; +@import "./internal/web/ui/badge.css"; +@import "./internal/web/ui/card.css"; +@import "./internal/web/ui/input.css"; +@import "./internal/web/ui/textarea.css"; +@import "./internal/web/ui/select.css"; +@import "./internal/web/ui/modal.css"; +@import "./internal/web/ui/empty-state.css"; +@import "./internal/web/ui/table.css"; +@import "./internal/web/ui/icon-button.css"; +@import "./internal/web/ui/form-field.css"; +@import "./internal/web/ui/spacing.css"; +``` + +Each CSS file is ported from `go-backend/internal/web/ui/{name}.css` with page-level selectors stripped (D-A03). Component CSS stays scoped to `.ui-*` class selectors only. + +--- + +## Class Naming Convention + +Go-backend uses two different class-assembly patterns. The backend must match go-backend exactly: + +| Component | Pattern | Example | +|-----------|---------|---------| +| Button | `ui-button ui-button-{tone} ui-button-{variant} ui-button-{size}` | `ui-button ui-button-solid ui-button-default ui-button-md` | +| Badge | `ui-badge ui-badge-{variant}` | `ui-badge ui-badge-info` | +| Icon button (solid) | `ui-icon-button ui-icon-button-solid ui-icon-button-{variant}` | `ui-icon-button ui-icon-button-solid ui-icon-button-neutral` | +| Icon button (ghost) | `borderless-icon-button ui-icon-button-ghost ui-icon-button-{variant}` | `borderless-icon-button ui-icon-button-ghost ui-icon-button-danger` | +| Space X | `ui-space-x ui-space-x-{step}` | `ui-space-x ui-space-x-md` | +| Space Y | `ui-space-y ui-space-y-{step}` | `ui-space-y ui-space-y-md` | + +**Note:** The existing `backend/internal/web/ui/variants.go` uses a DIFFERENT pattern: +`ui-button ui-button-{tone}-{variant}-{size}` (hyphen-joined). This diverges from go-backend. +The planner must decide whether to align backend to go-backend's space-separated pattern or keep the existing joined pattern and update the CSS to match. This is a **pre-planning decision required before Plan 01.** + +--- + +## Interaction Contract + +All interactive states are implemented in CSS only (no JavaScript for state). JavaScript is permitted only for: +- The select component dropdown open/close toggle (adding/removing `is-open` class and `hidden` attribute on the menu) +- Modal show/hide via HTMX swap (no JS state machines) + +| State | Mechanism | +|-------|-----------| +| Button hover | CSS `:hover` pseudo-class | +| Button active | CSS `:active` pseudo-class | +| Button focus | CSS `:focus-visible`, 3px ring `--color-focus-ring` | +| Button disabled | HTML `disabled` attribute + CSS `[disabled]` or `.is-disabled` | +| Input focus | CSS `:focus`, border-color change + 3px ring | +| Select open | JS adds `is-open` class; CSS rotates chevron, shows menu | +| Modal open | HTMX swaps backdrop+panel into `#modal-slot` | +| Modal close | HTMX swaps empty fragment into `#modal-slot` | +| Icon button focus | CSS `:focus-visible`, 3px ring `--color-focus-ring` | + +--- + +## Accessibility Contract + +- All interactive elements must have `min-height: 44px` (touch target — WCAG 2.5.5) +- Icon buttons must have an `aria-label` attribute (no visible text) +- Form fields must use `