---
phase: 1
slug: foundation
status: draft
shadcn_initialized: false
preset: none
created: 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):**

```html
<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`):**

```html
<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):**

```templ
@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):**

```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/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
