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.
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-getdemo. 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 inbackend/.
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-getbutton 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.jsis loaded once, at the bottom of<body>, withdefer.tailwind.cssis loaded in<head>so there is no FOUC.- No inline
<style>blocks. No inlinestyle="..."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). Nooutline-nonewithout a replacement ring. - Color contrast:
text-slate-900onbg-whiteandbg-slate-50≥ 7:1 (AAA body).text-whiteonbg-blue-600≥ 4.5:1 (AA large).text-slate-600onbg-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-motionis 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
*.cssfile and is imported intobackend/web/tailwind.input.cssvia@import "../internal/web/ui/{name}.css";. Tailwind utility classes used inside*.templfiles are picked up by the@source "../internal/web/**/*.templ"directive (Research Pitfall 3). - Variant/tone/size enums declared in
variants.goMUST 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 atvariants.go:90-150). ui_test.goMUST 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 theuipackage then) IconButton,Spacehelper (deferred — first need lands in Phase 3)Table,EmptyState(deferred — first need lands in Phase 3 tablo list)- Modal/dialog patterns —
Modalcomponent (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