xtablo-source/.planning/phases/01-foundation/01-UI-SPEC.md
Arthur Belleville 4d745f82c3
docs(01): add custom templ component library contract to UI-SPEC
Adds a Component Library Contract section locking in a small in-house
templ design-system under backend/internal/web/ui/, modeled on the
reference at go-backend/internal/web/ui/. Phase 1 ships only Button,
Card, and Badge; later phases extend the same package.
2026-05-14 16:58:27 +02:00

19 KiB

phase slug status shadcn_initialized preset created
1 foundation draft false none 2026-05-14

Phase 1 — UI Design Contract

Visual and interaction contract for the Foundation phase. This phase ships a Walking Skeleton: a single Tailwind-styled root page rendered by templ with one working hx-get demo. The tokens declared here are load-bearing for every subsequent phase. shadcn is not applicable — the stack is Go templ + HTMX + Tailwind v4 standalone, with no Node/JS toolchain in backend/.


Design System

Property Value
Tool none (no shadcn — Go/templ/HTMX stack, no React)
Preset not applicable
Component library custom — small in-house templ design-system under backend/internal/web/ui/ (modeled on the reference at go-backend/internal/web/ui/). Phase 1 ships a minimal core; later phases extend the same package.
Icon library none in Phase 1 (deferred — inline SVG when needed in Phase 3+)
Font System font stack: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif (Tailwind default font-sans)

Rationale: Phase 1 is a Walking Skeleton. Pulling in a webfont or icon library before there is any UI to motivate it adds bytes and bootstrap complexity for zero value. System fonts render instantly with no FOUT and align with the "single binary + static" thesis from CONTEXT D-10 / D-12.

Static assets (vendored, served from /static/, no CDN — per CONTEXT D-10):

  • htmx.min.js (HTMX v2.x)
  • tailwind.css (compiled by Tailwind standalone CLI v4.x)

Spacing Scale

Declared values (multiples of 4, mapped 1:1 to Tailwind defaults — no custom spacing tokens in Phase 1):

Token Value Tailwind utility Usage
xs 4px *-1 Inline icon gaps, tight inline padding
sm 8px *-2 Compact element gaps, button inner padding-y
md 16px *-4 Default element spacing, button inner padding-x, form field gaps
lg 24px *-6 Section internal padding, card padding
xl 32px *-8 Layout gaps between sections, page header padding-y
2xl 48px *-12 Major section breaks
3xl 64px *-16 Page-level vertical rhythm, hero spacing

Container: Page content is constrained to max-w-5xl (1024px) and centered with mx-auto. Horizontal page padding: px-4 on mobile (<640px), px-6 from sm: up.

Exceptions: none. Phase 1 has no touch-target-only elements; the hx-get demo button uses md/sm padding which yields >40px height.


Typography

Three sizes, two weights — enforced through Tailwind utility classes.

Role Size Tailwind Weight Line Height
Body 16px text-base 400 (font-normal) 1.5 (leading-normal)
Label / small 14px text-sm 400 (font-normal) 1.5 (leading-normal)
Heading (H2 / section) 20px text-xl 600 (font-semibold) 1.3 (leading-snug)
Display (H1 / page title) 28px text-3xl adjusted via text-[28px] or default text-2xl (24px) acceptable 600 (font-semibold) 1.2 (leading-tight)

Rule: Only weights 400 and 600 may appear in Phase 1 markup. No 500, no 700. Rule: Only the four sizes above may appear. No text-lg, text-2xl etc. unless they match the declared px value above.

Heading element mapping:

  • Page title (root page /): <h1> styled with the Display row.
  • Demo section heading: <h2> styled with the Heading row.
  • Body copy and the timestamp fragment: Body row.
  • The hx-get button label: Body row, weight 600 allowed for button text only.

Color

Single palette, light-mode only in Phase 1. Dark mode is deferred (no requirement from FOUND-01..05). All values are Tailwind v4 default palette references to keep the contract checkable from CSS alone.

Role Value Tailwind class Usage
Dominant (60%) #ffffff bg-white Page background, content surface
Secondary (30%) #f8fafc (slate-50) bg-slate-50 Header strip, demo card background, any panel that needs visual separation from the page
Foreground primary #0f172a (slate-900) text-slate-900 All headings and body text
Foreground muted #475569 (slate-600) text-slate-600 Secondary copy, helper text, the timestamp fragment when idle
Border #e2e8f0 (slate-200) border-slate-200 Card borders, divider lines
Accent (10%) #2563eb (blue-600) bg-blue-600, text-blue-600, hover:bg-blue-700 Reserved for: the primary hx-get demo button only. Hover state: blue-700. Focus ring: ring-2 ring-blue-600/40 ring-offset-2.
Accent foreground #ffffff text-white Text on accent backgrounds (button label)
Destructive #dc2626 (red-600) text-red-600, bg-red-600 Not used in Phase 1 (no destructive actions exist). Declared here so Phase 3+ inherits the token.
Success indicator #16a34a (green-600) text-green-600 /healthz "ok" badge if surfaced visually. Not strictly required for Phase 1 but reserved.

Accent reserved for: the hx-get demo button on the root page. Nothing else in Phase 1 may use blue-*. No links, no headings, no underlines in the brand blue.

60/30/10 split: ~60% white surface, ~30% slate-50 strip/card, ~10% accent confined to the single primary button.


Copywriting Contract

Element Copy
Page <title> Xtablo — Foundation
Page H1 Xtablo
Page subtitle (under H1, muted) Go + HTMX foundation. Sign-in and the Tablos workflow ship in later phases.
Demo section H2 HTMX demo
Demo helper text (above button) Click the button to fetch the server time as an HTML fragment.
Primary CTA (the only button) Fetch server time
Initial fragment placeholder (#demo-out empty state) No time fetched yet. (rendered in text-slate-600)
Fragment success payload <span>{ISO-8601 UTC timestamp}</span> — e.g. 2026-05-14T14:42:38Z
HTMX failure copy (server 5xx, rendered via hx-target-error or fallback) Could not reach the server. Refresh the page and try again.
/healthz JSON success {"status":"ok","db":"ok"} (locked in CONTEXT D-20)
/healthz JSON degraded {"status":"degraded","db":"down"} (locked in CONTEXT D-20)
Footer (small, muted, optional) Phase 1 · Walking skeleton

Destructive actions: none in Phase 1. The contract row exists for downstream phases but is intentionally empty here.

Tone rules:

  • Friendly, declarative, no marketing voice.
  • No emoji.
  • No exclamation marks.
  • Sentence case for headings (not Title Case).
  • Errors describe the problem and the next action ("Refresh the page and try again."), never just "Error".

Base Layout Contract

The base templ layout (templates/layout.templ) is the foundation every later phase extends. It must contain exactly the following structural regions:

<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{title}</title>
    <link rel="stylesheet" href="/static/tailwind.css">
  </head>
  <body class="min-h-screen bg-white text-slate-900 antialiased">
    <header class="bg-slate-50 border-b border-slate-200">
      <div class="mx-auto max-w-5xl px-4 sm:px-6 py-4">
        <!-- brand slot: <h1> on the root page; smaller mark on inner pages (later phases) -->
      </div>
    </header>
    <main class="mx-auto max-w-5xl px-4 sm:px-6 py-8">
      { children... }
    </main>
    <footer class="mx-auto max-w-5xl px-4 sm:px-6 py-6 text-sm text-slate-600">
      Phase 1 · Walking skeleton
    </footer>
    <script src="/static/htmx.min.js" defer></script>
  </body>
</html>

Rules:

  • htmx.min.js is loaded once, at the bottom of <body>, with defer.
  • tailwind.css is loaded in <head> so there is no FOUC.
  • No inline <style> blocks. No inline style="..." attributes.
  • No CDN references for any asset.
  • The <main> container width (max-w-5xl) and horizontal padding (px-4 sm:px-6) is the single content frame for the entire product. Later phases must not reinvent this container.

HTMX Interaction Pattern

The hx-get demo on the root page is the canonical interaction pattern. Every future HTMX call in the product must follow this shape unless a specific later phase contract overrides it.

Concern Contract
Trigger element <button> (or <a> for navigation, never <div hx-get>)
Required attributes hx-get (or hx-post), hx-target, hx-swap
hx-target value A CSS #id selector pointing to a stable element in the current document
hx-swap value innerHTML is the default. Use outerHTML only when the target itself is being replaced.
Indicator hx-indicator="#demo-spinner" references a <span class="htmx-indicator">…</span> styled with text-slate-600. Spinner content is the text Loading… in Phase 1 (no SVG spinner).
Disabled state during request The triggering button receives the class htmx-request:opacity-60 htmx-request:pointer-events-none via Tailwind variant.
Error response Server returns 4xx/5xx with a small HTML fragment containing the failure copy declared above. HTMX swaps it into the same target.
URL convention /{resource}/{action} (e.g. /demo/time). Fragments live under the same path tree as the page that requests them. No /api prefix in Phase 1 — HTMX endpoints are first-class routes.

Concrete demo markup (canonical):

<section class="rounded-lg border border-slate-200 bg-slate-50 p-6">
  <h2 class="text-xl font-semibold leading-snug">HTMX demo</h2>
  <p class="mt-2 text-base text-slate-600">
    Click the button to fetch the server time as an HTML fragment.
  </p>
  <div class="mt-4 flex items-center gap-4">
    <button
      type="button"
      hx-get="/demo/time"
      hx-target="#demo-out"
      hx-swap="innerHTML"
      hx-indicator="#demo-spinner"
      class="inline-flex items-center rounded-md bg-blue-600 px-4 py-2
             text-base font-semibold text-white
             hover:bg-blue-700
             focus:outline-none focus:ring-2 focus:ring-blue-600/40 focus:ring-offset-2
             htmx-request:opacity-60 htmx-request:pointer-events-none">
      Fetch server time
    </button>
    <span id="demo-spinner" class="htmx-indicator text-sm text-slate-600">Loading…</span>
  </div>
  <div id="demo-out" class="mt-4 text-base text-slate-600">
    No time fetched yet.
  </div>
</section>

Fragment response (from GET /demo/time):

<span class="text-slate-900">2026-05-14T14:42:38Z</span>

Content-Type: text/html; charset=utf-8. No JSON. No envelope.


State Inventory (Phase 1)

Each interactive surface declares its states explicitly. Phase 1 has exactly one interactive surface.

Surface States
Fetch server time button default · hover · focus · in-flight (htmx-request)
#demo-out fragment target empty (No time fetched yet.) · success (timestamp <span>) · error (failure copy)
/healthz (non-UI) ok (200 JSON) · degraded (503 JSON)

No loading skeletons. No empty-state illustrations. No toasts. These are intentionally out of scope for the Walking Skeleton and will be specified per-phase from Phase 3 onward.


Accessibility Floor

Minimum bar Phase 1 must meet. Later phases inherit and extend.

  • All interactive elements reachable by keyboard (Tab order follows DOM order).
  • Focus rings visible on the demo button (focus:ring-2 focus:ring-blue-600/40 focus:ring-offset-2). No outline-none without a replacement ring.
  • Color contrast: text-slate-900 on bg-white and bg-slate-50 ≥ 7:1 (AAA body). text-white on bg-blue-600 ≥ 4.5:1 (AA large). text-slate-600 on bg-white ≥ 4.5:1.
  • <html lang="en"> declared.
  • HTMX-swapped fragments do not require ARIA live regions in Phase 1 (single, user-initiated interaction). This becomes a requirement when async/auto-loading is introduced.
  • No motion or auto-playing animation in Phase 1, so prefers-reduced-motion is implicitly honored.

Component Library Contract

Phase 1 establishes a small custom templ design-system at backend/internal/web/ui/. The shape follows the reference at go-backend/internal/web/ui/ (props struct + templ component + variant/tone/size enums + per-component CSS layer). Layouts and pages MUST consume these components instead of hand-rolling Tailwind classes for primitives that already exist in the package — this is how token discipline is enforced across phases.

Package layout (Phase 1):

backend/internal/web/ui/
├── tokens.go          // semantic token constants (TokenPrimary, TokenDanger, …) — start small
├── variants.go        // Size / ButtonVariant / ButtonTone / BadgeVariant enums + class builders
├── helpers.go         // shared helpers (e.g. attribute merging, normalizers)
├── base.css           // resets, focus-ring base, html/body defaults (imported into tailwind.input.css)
├── button.templ       // <Button props={...}>; emits class via buttonClass(...)
├── button.css         // .ui-button + .ui-button-{solid,soft}-{default,…}-{sm,md,lg}
├── badge.templ        // <Badge props={Variant: ...}>
├── badge.css
├── card.templ         // <Card> { children... } — used for the demo section
├── card.css
└── ui_test.go         // smoke tests: each component renders with each variant without panicking

What Phase 1 ships (and ONLY this):

Component Props (struct) Variants in scope Notes
Button Label string, Variant ButtonVariant, Tone ButtonTone, Size Size, Type string, Attrs templ.Attributes Variant: default only; Tone: solid only; Size: md only Attrs is the HTMX escape hatch — hx-get, hx-target, hx-swap, hx-indicator flow through it. Other variants are declared in variants.go so later phases inherit the enum surface, but only the default/solid/md combination has a CSS rule in Phase 1.
Card Attrs templ.Attributes + children n/a Rounded slate-50 panel with slate-200 border (the demo section frame).
Badge Label string, Variant BadgeVariant info, success, danger declared; info and success rendered in Phase 1 (used by an optional /healthz UI badge). warning enum value exists with no CSS yet.
(no Input, Select, Modal, Table, EmptyState, IconButton, Space, Textarea, FormField in Phase 1) These live in the reference package but are explicitly deferred. They land in their first-needed phase (forms → Phase 2, modals/tables → Phase 3, etc.).

Rules:

  • Pages/layouts MUST NOT use raw <button class="bg-blue-600 …"> — they MUST use @ui.Button(ui.ButtonProps{...}). The same goes for the demo section frame: use @ui.Card, not a hand-rolled <section class="rounded-lg border …">.
  • The raw demo markup shown in the HTMX Interaction Pattern section above is the rendered output target — the templ source must produce it via the components.
  • CSS for each component lives in its own *.css file and is imported into backend/web/tailwind.input.css via @import "../internal/web/ui/{name}.css";. Tailwind utility classes used inside *.templ files are picked up by the @source "../internal/web/**/*.templ" directive (Research Pitfall 3).
  • Variant/tone/size enums declared in variants.go MUST mirror the reference package's enum names (SizeSM/MD/LG, ButtonVariantDefault/Neutral/Warning/Success/Danger, ButtonToneSolid/Soft, BadgeVariantInfo/Warning/Success/Danger). Later phases extend the CSS rules for these enums; they do not redefine the enums.
  • A normalizer pattern (normalizedSize, normalizedButtonVariant, …) is required for every enum, so zero-value props default to a safe variant (matches the reference at variants.go:90-150).
  • ui_test.go MUST render each shipped component with every variant declared in this contract (Phase 1 = Button-default-solid-md, Card, Badge-info, Badge-success) and assert no panic and that the expected root class name (ui-button, ui-card, ui-badge-info, …) appears in the output. This is the Wave 0 test surface for the UI package.

Canonical Button usage (the demo CTA, rewritten via the component):

@ui.Button(ui.ButtonProps{
    Label:   "Fetch server time",
    Variant: ui.ButtonVariantDefault,
    Tone:    ui.ButtonToneSolid,
    Size:    ui.SizeMD,
    Type:    "button",
    Attrs: templ.Attributes{
        "hx-get":       "/demo/time",
        "hx-target":    "#demo-out",
        "hx-swap":      "innerHTML",
        "hx-indicator": "#demo-spinner",
    },
})

This must render the exact HTML shown in the HTMX Interaction Pattern section (same class soup, same attributes, same accessibility floor). The Tailwind classes themselves live in button.css under .ui-button-solid-default-md — pages do not repeat them.

Deferred from the reference package (do NOT implement in Phase 1): Input, Textarea, Select, FormField, Modal, Table, EmptyState, IconButton, Space. Each lands in the phase that first needs it, extending this same package.


Registry Safety

Registry Blocks Used Safety Gate
shadcn official none not applicable (no shadcn; stack is Go/templ/HTMX/Tailwind)
third-party none not applicable

No registry-installed components in Phase 1. All markup is hand-authored templ + Tailwind utility classes.


Tailwind Configuration Contract

Phase 1 commits to Tailwind v4 standalone CLI (CONTEXT D-12). The input CSS file pins the content sources so JIT does not purge classes that only appear in .templ files (Research Pitfall 3).

tailwind.input.css (canonical):

@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/card.css";
@import "../internal/web/ui/badge.css";

No tailwind.config.js. No custom theme extensions in Phase 1 — every token in this contract is reachable through Tailwind defaults. Custom tokens land only when a later phase has a justified need.


What This Contract Deliberately Does Not Cover

  • Dark mode (deferred — no FOUND requirement)
  • Icons (deferred — first need lands in Phase 3 tablo list)
  • Form components — Input, Textarea, Select, FormField (deferred — first form is the login form in Phase 2; extend the ui package then)
  • IconButton, Space helper (deferred — first need lands in Phase 3)
  • Table, EmptyState (deferred — first need lands in Phase 3 tablo list)
  • Modal/dialog patterns — Modal component (deferred — first modal is "create tablo" in Phase 3)
  • Toasts / flash messages (deferred — first need lands in Phase 2 for login failures)
  • Empty-state illustrations (deferred — first list-empty state lands in Phase 3)
  • Kanban board layout (Phase 4)
  • File upload UI (Phase 5)

Each later phase's UI-SPEC will extend (never override) the tokens declared here.


Checker Sign-Off

  • Dimension 1 Copywriting: PASS
  • Dimension 2 Visuals: PASS
  • Dimension 3 Color: PASS
  • Dimension 4 Typography: PASS
  • Dimension 5 Spacing: PASS
  • Dimension 6 Registry Safety: PASS

Approval: pending