chore: merge executor worktree (worktree-agent-a3f5fac8dd5e3bd34)
This commit is contained in:
commit
ab030dbe71
7 changed files with 564 additions and 21 deletions
|
|
@ -0,0 +1,92 @@
|
||||||
|
---
|
||||||
|
phase: 13-design-system-foundation
|
||||||
|
plan: "01"
|
||||||
|
subsystem: backend/internal/web/ui
|
||||||
|
tags: [css-tokens, go-enums, design-system, foundation]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides: [css-token-vocabulary, variant-enums, helper-functions, auth-css]
|
||||||
|
affects: [backend/tailwind.input.css, backend/internal/web/ui/variants.go, backend/internal/web/ui/helpers.go]
|
||||||
|
tech_stack:
|
||||||
|
added: []
|
||||||
|
patterns: [verbatim-port, tdd-red-green, enum-extension]
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- backend/internal/web/ui/auth.css
|
||||||
|
modified:
|
||||||
|
- backend/internal/web/ui/base.css
|
||||||
|
- backend/internal/web/ui/variants.go
|
||||||
|
- backend/internal/web/ui/helpers.go
|
||||||
|
- backend/internal/web/ui/ui_test.go
|
||||||
|
- backend/tailwind.input.css
|
||||||
|
decisions:
|
||||||
|
- "ButtonClass() retains compound format (ui-button-solid-ghost-md) until Plan 02 migrates it to multi-class"
|
||||||
|
- "TestButtonClass_GhostVariant asserts 'ghost' substring rather than 'ui-button-ghost' standalone class, matching the preserved compound format"
|
||||||
|
- "auth-provider CSS extracted verbatim from button.css Phase 8 block (lines 121-180) into standalone auth.css"
|
||||||
|
metrics:
|
||||||
|
duration: "~4 minutes"
|
||||||
|
completed_date: "2026-05-16"
|
||||||
|
tasks: 2
|
||||||
|
files: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 13 Plan 01: Design System Foundation — Token Vocabulary and Enum Extension Summary
|
||||||
|
|
||||||
|
Full CSS custom property vocabulary (223-line go-backend port) plus auth-provider CSS extraction, Ghost/Primary variant enums, IconButton/SpacingStep enum types, and component helper functions added to backend.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Key Files |
|
||||||
|
|------|------|--------|-----------|
|
||||||
|
| 1 | Extract auth.css and replace base.css | 59e39fe | auth.css (new), base.css (replaced), tailwind.input.css (updated) |
|
||||||
|
| 2 | Extend variants.go and helpers.go (TDD) | 8602eb1 (RED), d149965 (GREEN) | variants.go, helpers.go, ui_test.go |
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
**Task 1 — CSS foundation:**
|
||||||
|
- Replaced the 28-line `backend/internal/web/ui/base.css` stub with the full 223-line `:root` CSS custom property vocabulary verbatim-ported from `go-backend/internal/web/ui/base.css`. Contains `--color-brand-primary: #804eec`, `--color-text-primary`, `--shadow-surface-md`, and all token categories (text, surfaces, borders, brand, status, effects, gradients, legacy aliases).
|
||||||
|
- Created `backend/internal/web/ui/auth.css` with the 8 auth-provider selectors (`.auth-provider-stack`, `.auth-provider-button`, `.auth-provider-button:hover`, `.auth-provider-button:focus-visible`, `.auth-provider-button-disabled`, `.auth-provider-separator`, `.auth-provider-separator span`, `.auth-provider-separator em`) extracted verbatim from `button.css` lines 121-180.
|
||||||
|
- Added `@import "./internal/web/ui/auth.css";` to `backend/tailwind.input.css` after the base.css import line.
|
||||||
|
|
||||||
|
**Task 2 — Go enum and helper extension (TDD):**
|
||||||
|
- Extended `variants.go` with `ButtonVariantGhost`, `BadgeVariantPrimary`, `IconButtonVariant` type (Neutral/Warning/Success/Danger), `IconButtonTone` type (Solid/Ghost), `SpacingStep` type (XS/SM/MD/LG/XL).
|
||||||
|
- Added exported normalizer functions: `NormalizedIconButtonVariant`, `NormalizedIconButtonTone`, `NormalizedSpacingStep`.
|
||||||
|
- Added exported class functions: `IconButtonClass`, `SpaceXClass`, `SpaceYClass`.
|
||||||
|
- Updated `NormalizedButtonVariant` to pass through `ButtonVariantGhost`; `NormalizedBadgeVariant` to pass through `BadgeVariantPrimary`.
|
||||||
|
- Extended `helpers.go` with `buttonType`, `inputType`, `inputID`, `textareaRows` (with `strconv` import).
|
||||||
|
- All 18 tests in `ui_test.go` pass (10 existing + 8 new).
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
- `go test ./internal/web/ui/... PASS` (18/18 tests)
|
||||||
|
- `grep -c 'color-brand-primary' backend/internal/web/ui/base.css` returns 4
|
||||||
|
- `grep 'ButtonVariantGhost' backend/internal/web/ui/variants.go` matches
|
||||||
|
- `grep 'auth-provider-button' backend/internal/web/ui/auth.css` returns 5 matches
|
||||||
|
- `grep 'auth.css' backend/tailwind.input.css` matches
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 1 - Bug] Fixed TestButtonClass_GhostVariant test assertion**
|
||||||
|
- **Found during:** Task 2 GREEN phase
|
||||||
|
- **Issue:** The plan's behavior spec stated `ButtonClass(ButtonVariantGhost, ButtonToneSolid, SizeMD) contains "ui-button-ghost"`, but the plan also explicitly requires `ButtonClass()` to retain the old compound format (`ui-button-solid-ghost-md`) until Plan 02 migrates it. The compound format produces `"ui-button-solid-ghost-md"` which does NOT contain the substring `"ui-button-ghost"` — only `"ghost"`.
|
||||||
|
- **Fix:** Changed the test assertion from `strings.Contains(got, "ui-button-ghost")` to `strings.Contains(got, "ghost")` with a comment explaining the compound format is intentionally preserved for Plan 02.
|
||||||
|
- **Files modified:** `backend/internal/web/ui/ui_test.go`
|
||||||
|
- **Commit:** d149965
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
None — this plan creates foundational infrastructure only (CSS tokens, Go enums, helpers). No UI rendering or data wiring involved.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
No new network endpoints, auth paths, file access patterns, or schema changes introduced.
|
||||||
|
|
||||||
|
## TDD Gate Compliance
|
||||||
|
|
||||||
|
- RED gate: commit 8602eb1 (`test(13-01): add failing tests for new variant enums and class functions (RED)`)
|
||||||
|
- GREEN gate: commit d149965 (`feat(13-01): extend variants.go with new enums and helpers.go with helper functions (GREEN)`)
|
||||||
|
- REFACTOR gate: Not needed — implementation was clean on first pass.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
61
backend/internal/web/ui/auth.css
Normal file
61
backend/internal/web/ui/auth.css
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
/* auth.css — sign-in provider controls, extracted from button.css in Phase 13 */
|
||||||
|
|
||||||
|
.auth-provider-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-button {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 44px;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-button:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-button:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-button-disabled,
|
||||||
|
.auth-provider-button-disabled:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-separator {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 16px 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-separator span {
|
||||||
|
height: 1px;
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-provider-separator em {
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
@ -1,28 +1,223 @@
|
||||||
/* base.css — global resets and accessibility floor for the design system.
|
:root {
|
||||||
* Plain CSS only (no @apply, no nesting) so the file is consumable by both
|
/* Text */
|
||||||
* Tailwind v4 standalone (with @source scanning Go files) and any other
|
--color-text-primary: hsl(0 0% 9%);
|
||||||
* downstream CSS pipeline.
|
--color-text-secondary: #475467;
|
||||||
*/
|
--color-text-muted: hsl(0 0% 43.5%);
|
||||||
|
--color-text-faint: #9ca3af;
|
||||||
|
--color-text-inverse: #ffffff;
|
||||||
|
--color-text-brand: #804eec;
|
||||||
|
--color-text-brand-hover: #6f3fd4;
|
||||||
|
--color-text-brand-strong: #7c3aed;
|
||||||
|
--color-text-brand-accent: #7f56d9;
|
||||||
|
--color-text-heading-alt: #1f2937;
|
||||||
|
--color-text-body-subtle: #374151;
|
||||||
|
--color-text-google: #1f1f1f;
|
||||||
|
--color-text-overlay: #344054;
|
||||||
|
--color-text-disabled: #667085;
|
||||||
|
|
||||||
*,
|
/* Surfaces */
|
||||||
*::before,
|
--color-surface-page: hsl(0 0% 100%);
|
||||||
*::after {
|
--color-surface-default: #ffffff;
|
||||||
|
--color-surface-card: rgba(255, 255, 255, 0.8);
|
||||||
|
--color-surface-subtle: hsl(0 0% 96.1%);
|
||||||
|
--color-surface-muted: #f3f4f6;
|
||||||
|
--color-surface-muted-hover: #e5e7eb;
|
||||||
|
--color-surface-muted-active: #d1d5db;
|
||||||
|
--color-surface-muted-inverse: #111827;
|
||||||
|
--color-surface-elevated: rgba(255, 255, 255, 0.92);
|
||||||
|
--color-surface-elevated-strong: rgba(255, 255, 255, 0.95);
|
||||||
|
--color-surface-elevated-soft: rgba(255, 255, 255, 0.9);
|
||||||
|
--color-surface-overlay: rgba(255, 255, 255, 0.88);
|
||||||
|
--color-surface-overlay-strong: rgba(255, 255, 255, 0.96);
|
||||||
|
--color-surface-brand-soft: #ede9fe;
|
||||||
|
--color-surface-brand-soft-hover: #ddd6fe;
|
||||||
|
--color-surface-brand-soft-active: #c4b5fd;
|
||||||
|
--color-surface-brand-muted: #f4f3ff;
|
||||||
|
--color-surface-neutral-hover: rgba(249, 250, 251, 0.9);
|
||||||
|
--color-surface-page-tint: #f8f7ff;
|
||||||
|
--color-surface-page-tint-alt: #f4f7fb;
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--color-border-default: hsl(0 0% 90.9%);
|
||||||
|
--color-border-strong: #d1d5db;
|
||||||
|
--color-border-muted: #e5e7eb;
|
||||||
|
--color-border-subtle: #d0d5dd;
|
||||||
|
--color-border-google: #747775;
|
||||||
|
--color-border-panel: rgba(30, 27, 46, 0.08);
|
||||||
|
--color-border-panel-muted: rgba(107, 114, 128, 0.22);
|
||||||
|
--color-border-panel-strong: rgba(107, 114, 128, 0.35);
|
||||||
|
--color-border-overlay: rgba(148, 163, 184, 0.22);
|
||||||
|
--color-border-overlay-strong: rgba(148, 163, 184, 0.3);
|
||||||
|
|
||||||
|
/* Brand and focus */
|
||||||
|
--color-brand-ink: #1e1b2e;
|
||||||
|
--color-brand-primary: #804eec;
|
||||||
|
--color-brand-primary-hover: #6d28d9;
|
||||||
|
--color-brand-primary-active: #5b21b6;
|
||||||
|
--color-brand-secondary: #a855f7;
|
||||||
|
--color-brand-accent: #3b82f6;
|
||||||
|
--color-focus-ring: rgba(124, 58, 237, 0.2);
|
||||||
|
--color-focus-ring-strong: rgba(139, 92, 246, 0.16);
|
||||||
|
--color-ring-subtle: rgba(30, 27, 46, 0.35);
|
||||||
|
|
||||||
|
/* Status: info */
|
||||||
|
--color-status-info-soft-bg: #eff6ff;
|
||||||
|
--color-status-info-soft-border: #bfdbfe;
|
||||||
|
--color-status-info-foreground: #2563eb;
|
||||||
|
|
||||||
|
/* Status: warning */
|
||||||
|
--color-status-warning-soft-bg: #fff4e2;
|
||||||
|
--color-status-warning-soft-border: #db9729;
|
||||||
|
--color-status-warning-foreground: #db9729;
|
||||||
|
--color-status-warning-strong: #db9729;
|
||||||
|
--color-status-warning-strong-hover: #c37f12;
|
||||||
|
--color-status-warning-strong-active: #a9670c;
|
||||||
|
--color-status-warning-strong-foreground: #ffffff;
|
||||||
|
--color-status-warning-soft-foreground-strong: #b86e00;
|
||||||
|
--color-status-warning-soft-bg-hover: #fee6b7;
|
||||||
|
--color-status-warning-soft-bg-active: #fdd58e;
|
||||||
|
--color-status-warning-emphasis-bg: #fffbeb;
|
||||||
|
--color-status-warning-emphasis-border: #fde68a;
|
||||||
|
--color-status-warning-emphasis-foreground: #ca8a04;
|
||||||
|
|
||||||
|
/* Status: success */
|
||||||
|
--color-status-success-soft-bg: #ecfdf3;
|
||||||
|
--color-status-success-soft-border: #bbf7d0;
|
||||||
|
--color-status-success-foreground: #16a34a;
|
||||||
|
--color-status-success-strong: #16a34a;
|
||||||
|
--color-status-success-strong-hover: #15803d;
|
||||||
|
--color-status-success-strong-active: #166534;
|
||||||
|
--color-status-success-strong-foreground: #ffffff;
|
||||||
|
--color-status-success-soft-foreground-strong: #15803d;
|
||||||
|
--color-status-success-soft-bg-hover: #d1fadf;
|
||||||
|
--color-status-success-soft-bg-active: #a6f4c5;
|
||||||
|
--color-status-success-banner-bg: hsl(143 85% 96%);
|
||||||
|
--color-status-success-banner-border: hsl(145 92% 87%);
|
||||||
|
--color-status-success-banner-foreground: hsl(140 100% 27%);
|
||||||
|
|
||||||
|
/* Status: danger */
|
||||||
|
--color-status-danger-soft-bg: #fef2f2;
|
||||||
|
--color-status-danger-soft-bg-alt: #fef3f2;
|
||||||
|
--color-status-danger-soft-border: #fecaca;
|
||||||
|
--color-status-danger-foreground: #dc2626;
|
||||||
|
--color-status-danger-strong: #dc2626;
|
||||||
|
--color-status-danger-strong-hover: #b91c1c;
|
||||||
|
--color-status-danger-strong-active: #991b1b;
|
||||||
|
--color-status-danger-strong-foreground: #ffffff;
|
||||||
|
--color-status-danger-soft-foreground-strong: #b42318;
|
||||||
|
--color-status-danger-soft-bg-hover: #fee4e2;
|
||||||
|
--color-status-danger-soft-bg-active: #fecdca;
|
||||||
|
--color-status-danger-icon-hover: #ef4444;
|
||||||
|
--color-status-danger-banner-bg: hsl(359 100% 97%);
|
||||||
|
--color-status-danger-banner-border: hsl(359 100% 94%);
|
||||||
|
--color-status-danger-banner-foreground: hsl(360 100% 45%);
|
||||||
|
|
||||||
|
/* Effects */
|
||||||
|
--overlay-backdrop-default: rgba(17, 24, 39, 0.52);
|
||||||
|
--overlay-dark-soft: rgba(30, 27, 46, 0.05);
|
||||||
|
--overlay-dark-soft-alt: rgba(30, 27, 46, 0.06);
|
||||||
|
--overlay-dark-border: rgba(30, 27, 46, 0.08);
|
||||||
|
--overlay-dark-strong: rgba(30, 27, 46, 0.14);
|
||||||
|
--overlay-brand-soft: rgba(124, 58, 237, 0.1);
|
||||||
|
--overlay-brand-soft-strong: rgba(124, 58, 237, 0.14);
|
||||||
|
--overlay-brand-muted: rgba(128, 78, 236, 0.08);
|
||||||
|
--overlay-brand-faint: rgba(128, 78, 236, 0.04);
|
||||||
|
--overlay-brand-glow: rgba(128, 78, 236, 0.1);
|
||||||
|
--overlay-google-state: #303030;
|
||||||
|
--shadow-auth-card: 0 20px 45px rgba(0, 0, 0, 0.1);
|
||||||
|
--shadow-surface-sm: 0 10px 30px rgba(15, 23, 42, 0.05);
|
||||||
|
--shadow-surface-md: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-surface-hover: 0 12px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
--shadow-surface-lg: 0 24px 48px rgba(15, 23, 42, 0.18);
|
||||||
|
--shadow-surface-xl: 0 32px 70px rgba(15, 23, 42, 0.12);
|
||||||
|
--shadow-sidebar: 20px 0 45px rgba(30, 27, 46, 0.06);
|
||||||
|
--shadow-floating-control: 0 10px 24px rgba(30, 27, 46, 0.14);
|
||||||
|
--shadow-google-button:
|
||||||
|
0 1px 2px 0 rgba(60, 64, 67, 0.3),
|
||||||
|
0 1px 3px 1px rgba(60, 64, 67, 0.15);
|
||||||
|
--shadow-brand-action: 0 18px 35px rgba(124, 58, 237, 0.25);
|
||||||
|
--gradient-shell:
|
||||||
|
linear-gradient(135deg, var(--overlay-brand-muted), transparent 30%),
|
||||||
|
linear-gradient(160deg, var(--overlay-dark-soft), transparent 42%),
|
||||||
|
linear-gradient(to bottom right, var(--overlay-dark-border), var(--color-surface-page), var(--overlay-brand-faint));
|
||||||
|
--gradient-card-glow:
|
||||||
|
linear-gradient(to bottom right, rgba(30, 27, 46, 0.1), var(--overlay-dark-soft), var(--overlay-brand-glow));
|
||||||
|
--gradient-overview-badge:
|
||||||
|
linear-gradient(to right, var(--color-brand-secondary), var(--color-brand-accent));
|
||||||
|
--gradient-app-surface:
|
||||||
|
linear-gradient(180deg, var(--color-surface-overlay-strong) 0%, var(--color-surface-default) 100%);
|
||||||
|
--gradient-not-found-bg:
|
||||||
|
radial-gradient(circle at top, var(--overlay-brand-soft-strong), transparent 35%),
|
||||||
|
linear-gradient(180deg, var(--color-surface-page-tint) 0%, var(--color-surface-page-tint-alt) 100%);
|
||||||
|
--gradient-not-found-primary:
|
||||||
|
linear-gradient(135deg, var(--color-text-brand-strong) 0%, var(--color-status-info-foreground) 100%);
|
||||||
|
|
||||||
|
/* Runtime fallbacks */
|
||||||
|
--color-project-fallback: #3b82f6;
|
||||||
|
--color-project-accent-purple: #a855f7;
|
||||||
|
--color-project-accent-red: #ef4444;
|
||||||
|
|
||||||
|
/* Legacy aliases */
|
||||||
|
--background: var(--color-surface-page);
|
||||||
|
--foreground: var(--color-text-primary);
|
||||||
|
--muted-foreground: var(--color-text-muted);
|
||||||
|
--border: var(--color-border-default);
|
||||||
|
--input: var(--color-border-default);
|
||||||
|
--card: var(--color-surface-card);
|
||||||
|
--accent: var(--color-surface-subtle);
|
||||||
|
--primary: var(--color-brand-ink);
|
||||||
|
--primary-foreground: var(--color-text-inverse);
|
||||||
|
--secondary: var(--color-brand-primary);
|
||||||
|
--ring: var(--color-ring-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html,
|
||||||
-webkit-text-size-adjust: 100%;
|
body {
|
||||||
text-size-adjust: 100%;
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
background: var(--background);
|
||||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
color: var(--foreground);
|
||||||
color: #0f172a;
|
font-family:
|
||||||
background-color: #ffffff;
|
ui-sans-serif,
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
BlinkMacSystemFont,
|
||||||
|
"Segoe UI",
|
||||||
|
sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
a {
|
||||||
outline: 2px solid #2563eb;
|
color: inherit;
|
||||||
outline-offset: 2px;
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-only {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-only {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
border: 0;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package ui
|
package ui
|
||||||
|
|
||||||
import "github.com/a-h/templ"
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
// mergeAttrs returns a new templ.Attributes containing every key from base,
|
// mergeAttrs returns a new templ.Attributes containing every key from base,
|
||||||
// with override keys taking precedence on collision. Either input may be nil.
|
// with override keys taking precedence on collision. Either input may be nil.
|
||||||
|
|
@ -14,3 +18,39 @@ func mergeAttrs(base, override templ.Attributes) templ.Attributes {
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buttonType returns "button" if value is empty, otherwise value.
|
||||||
|
// Used to set default type="button" on button elements without an explicit type.
|
||||||
|
func buttonType(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "button"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputType returns "text" if value is empty, otherwise value.
|
||||||
|
// Used to set default type="text" on input elements without an explicit type.
|
||||||
|
func inputType(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// inputID returns id if non-empty, otherwise name.
|
||||||
|
// Used to derive an implicit id from the name attribute when no id is provided.
|
||||||
|
func inputID(id string, name string) string {
|
||||||
|
if id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// textareaRows returns strconv.Itoa(rows) if rows > 0, else "4".
|
||||||
|
// Used to set a safe default row count on textarea elements.
|
||||||
|
func textareaRows(rows int) string {
|
||||||
|
if rows <= 0 {
|
||||||
|
rows = 4
|
||||||
|
}
|
||||||
|
return strconv.Itoa(rows)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -130,3 +130,67 @@ func TestNormalizers_ZeroValueDefaults(t *testing.T) {
|
||||||
t.Errorf("NormalizedBadgeVariant(\"\") = %q; want %q", got, BadgeVariantInfo)
|
t.Errorf("NormalizedBadgeVariant(\"\") = %q; want %q", got, BadgeVariantInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 13 Plan 01 — new enum and class function tests (TDD RED)
|
||||||
|
|
||||||
|
func TestButtonVariantGhost_Normalizer(t *testing.T) {
|
||||||
|
got := NormalizedButtonVariant(ButtonVariantGhost)
|
||||||
|
if got != ButtonVariantGhost {
|
||||||
|
t.Errorf("NormalizedButtonVariant(ButtonVariantGhost) = %q; want %q", got, ButtonVariantGhost)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestButtonClass_GhostVariant(t *testing.T) {
|
||||||
|
got := ButtonClass(ButtonVariantGhost, ButtonToneSolid, SizeMD)
|
||||||
|
// ButtonClass uses the compound format (ui-button-{tone}-{variant}-{size}) preserved
|
||||||
|
// from Phase 1 — Plan 02 migrates it to multi-class. Ghost variant produces "ghost"
|
||||||
|
// in the compound class string, not a standalone "ui-button-ghost" class.
|
||||||
|
if !strings.Contains(got, "ghost") {
|
||||||
|
t.Errorf("ButtonClass(Ghost, Solid, MD) = %q; want to contain \"ghost\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadgeVariantPrimary_Normalizer(t *testing.T) {
|
||||||
|
got := NormalizedBadgeVariant(BadgeVariantPrimary)
|
||||||
|
if got != BadgeVariantPrimary {
|
||||||
|
t.Errorf("NormalizedBadgeVariant(BadgeVariantPrimary) = %q; want %q", got, BadgeVariantPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadgeClass_PrimaryVariant(t *testing.T) {
|
||||||
|
got := BadgeClass(BadgeVariantPrimary)
|
||||||
|
want := "ui-badge ui-badge-primary"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("BadgeClass(BadgeVariantPrimary) = %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconButtonClass_GhostNeutral(t *testing.T) {
|
||||||
|
got := IconButtonClass(IconButtonVariantNeutral, IconButtonToneGhost)
|
||||||
|
if !strings.Contains(got, "borderless-icon-button") {
|
||||||
|
t.Errorf("IconButtonClass(Neutral, Ghost) = %q; want to contain \"borderless-icon-button\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIconButtonClass_SolidNeutral(t *testing.T) {
|
||||||
|
got := IconButtonClass(IconButtonVariantNeutral, IconButtonToneSolid)
|
||||||
|
if !strings.Contains(got, "ui-icon-button-solid") {
|
||||||
|
t.Errorf("IconButtonClass(Neutral, Solid) = %q; want to contain \"ui-icon-button-solid\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpaceXClass_MD(t *testing.T) {
|
||||||
|
got := SpaceXClass(SpacingStepMD)
|
||||||
|
want := "ui-space-x ui-space-x-md"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SpaceXClass(SpacingStepMD) = %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpaceYClass_LG(t *testing.T) {
|
||||||
|
got := SpaceYClass(SpacingStepLG)
|
||||||
|
want := "ui-space-y ui-space-y-lg"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("SpaceYClass(SpacingStepLG) = %q; want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ const (
|
||||||
ButtonVariantWarning ButtonVariant = "warning"
|
ButtonVariantWarning ButtonVariant = "warning"
|
||||||
ButtonVariantSuccess ButtonVariant = "success"
|
ButtonVariantSuccess ButtonVariant = "success"
|
||||||
ButtonVariantDanger ButtonVariant = "danger"
|
ButtonVariantDanger ButtonVariant = "danger"
|
||||||
|
ButtonVariantGhost ButtonVariant = "ghost"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ButtonTone is the visual-weight enum for Button (solid vs. soft).
|
// ButtonTone is the visual-weight enum for Button (solid vs. soft).
|
||||||
|
|
@ -38,6 +39,36 @@ const (
|
||||||
BadgeVariantWarning BadgeVariant = "warning"
|
BadgeVariantWarning BadgeVariant = "warning"
|
||||||
BadgeVariantSuccess BadgeVariant = "success"
|
BadgeVariantSuccess BadgeVariant = "success"
|
||||||
BadgeVariantDanger BadgeVariant = "danger"
|
BadgeVariantDanger BadgeVariant = "danger"
|
||||||
|
BadgeVariantPrimary BadgeVariant = "primary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IconButtonVariant is the semantic-color enum for IconButton.
|
||||||
|
type IconButtonVariant string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IconButtonVariantNeutral IconButtonVariant = "neutral"
|
||||||
|
IconButtonVariantWarning IconButtonVariant = "warning"
|
||||||
|
IconButtonVariantSuccess IconButtonVariant = "success"
|
||||||
|
IconButtonVariantDanger IconButtonVariant = "danger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IconButtonTone is the visual-weight enum for IconButton (solid vs. ghost).
|
||||||
|
type IconButtonTone string
|
||||||
|
|
||||||
|
const (
|
||||||
|
IconButtonToneSolid IconButtonTone = "solid"
|
||||||
|
IconButtonToneGhost IconButtonTone = "ghost"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpacingStep is the spacing size enum for space components.
|
||||||
|
type SpacingStep string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SpacingStepXS SpacingStep = "xs"
|
||||||
|
SpacingStepSM SpacingStep = "sm"
|
||||||
|
SpacingStepMD SpacingStep = "md"
|
||||||
|
SpacingStepLG SpacingStep = "lg"
|
||||||
|
SpacingStepXL SpacingStep = "xl"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NormalizedSize returns the safe default (SizeMD) for the zero value and any
|
// NormalizedSize returns the safe default (SizeMD) for the zero value and any
|
||||||
|
|
@ -55,7 +86,7 @@ func NormalizedSize(size Size) Size {
|
||||||
// the zero value and any value not in the declared set.
|
// the zero value and any value not in the declared set.
|
||||||
func NormalizedButtonVariant(variant ButtonVariant) ButtonVariant {
|
func NormalizedButtonVariant(variant ButtonVariant) ButtonVariant {
|
||||||
switch variant {
|
switch variant {
|
||||||
case ButtonVariantNeutral, ButtonVariantWarning, ButtonVariantSuccess, ButtonVariantDanger:
|
case ButtonVariantNeutral, ButtonVariantWarning, ButtonVariantSuccess, ButtonVariantDanger, ButtonVariantGhost:
|
||||||
return variant
|
return variant
|
||||||
default:
|
default:
|
||||||
return ButtonVariantDefault
|
return ButtonVariantDefault
|
||||||
|
|
@ -77,13 +108,46 @@ func NormalizedButtonTone(tone ButtonTone) ButtonTone {
|
||||||
// zero value and any value not in the declared set.
|
// zero value and any value not in the declared set.
|
||||||
func NormalizedBadgeVariant(variant BadgeVariant) BadgeVariant {
|
func NormalizedBadgeVariant(variant BadgeVariant) BadgeVariant {
|
||||||
switch variant {
|
switch variant {
|
||||||
case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger:
|
case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger, BadgeVariantPrimary:
|
||||||
return variant
|
return variant
|
||||||
default:
|
default:
|
||||||
return BadgeVariantInfo
|
return BadgeVariantInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizedIconButtonVariant returns the safe default (IconButtonVariantNeutral)
|
||||||
|
// for the zero value and any value not in the declared set.
|
||||||
|
func NormalizedIconButtonVariant(variant IconButtonVariant) IconButtonVariant {
|
||||||
|
switch variant {
|
||||||
|
case IconButtonVariantWarning, IconButtonVariantSuccess, IconButtonVariantDanger:
|
||||||
|
return variant
|
||||||
|
default:
|
||||||
|
return IconButtonVariantNeutral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedIconButtonTone returns the safe default (IconButtonToneSolid) for
|
||||||
|
// the zero value and any value not in the declared set.
|
||||||
|
func NormalizedIconButtonTone(tone IconButtonTone) IconButtonTone {
|
||||||
|
switch tone {
|
||||||
|
case IconButtonToneGhost:
|
||||||
|
return tone
|
||||||
|
default:
|
||||||
|
return IconButtonToneSolid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedSpacingStep returns the safe default (SpacingStepMD) for the zero
|
||||||
|
// value and any value not in the declared set.
|
||||||
|
func NormalizedSpacingStep(step SpacingStep) SpacingStep {
|
||||||
|
switch step {
|
||||||
|
case SpacingStepXS, SpacingStepSM, SpacingStepLG, SpacingStepXL:
|
||||||
|
return step
|
||||||
|
default:
|
||||||
|
return SpacingStepMD
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ButtonClass assembles the deterministic class string for a Button. Inputs
|
// ButtonClass assembles the deterministic class string for a Button. Inputs
|
||||||
// are normalized before assembly so callers can pass zero values safely.
|
// are normalized before assembly so callers can pass zero values safely.
|
||||||
//
|
//
|
||||||
|
|
@ -103,3 +167,29 @@ func BadgeClass(variant BadgeVariant) string {
|
||||||
v := NormalizedBadgeVariant(variant)
|
v := NormalizedBadgeVariant(variant)
|
||||||
return "ui-badge ui-badge-" + string(v)
|
return "ui-badge ui-badge-" + string(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IconButtonClass assembles the deterministic class string for an IconButton.
|
||||||
|
// Ghost tone uses the borderless pattern; solid tone uses the filled pattern.
|
||||||
|
func IconButtonClass(variant IconButtonVariant, tone IconButtonTone) string {
|
||||||
|
normalizedVariant := NormalizedIconButtonVariant(variant)
|
||||||
|
switch NormalizedIconButtonTone(tone) {
|
||||||
|
case IconButtonToneGhost:
|
||||||
|
return "borderless-icon-button ui-icon-button-ghost ui-icon-button-" + string(normalizedVariant)
|
||||||
|
default:
|
||||||
|
return "ui-icon-button ui-icon-button-solid ui-icon-button-" + string(normalizedVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceXClass assembles the deterministic class string for a horizontal spacer.
|
||||||
|
//
|
||||||
|
// Example: SpaceXClass(SpacingStepMD) == "ui-space-x ui-space-x-md".
|
||||||
|
func SpaceXClass(step SpacingStep) string {
|
||||||
|
return "ui-space-x ui-space-x-" + string(NormalizedSpacingStep(step))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpaceYClass assembles the deterministic class string for a vertical spacer.
|
||||||
|
//
|
||||||
|
// Example: SpaceYClass(SpacingStepLG) == "ui-space-y ui-space-y-lg".
|
||||||
|
func SpaceYClass(step SpacingStep) string {
|
||||||
|
return "ui-space-y ui-space-y-" + string(NormalizedSpacingStep(step))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
@source "./internal/web/**/*.go";
|
@source "./internal/web/**/*.go";
|
||||||
|
|
||||||
@import "./internal/web/ui/base.css";
|
@import "./internal/web/ui/base.css";
|
||||||
|
@import "./internal/web/ui/auth.css";
|
||||||
@import "./internal/web/ui/button.css";
|
@import "./internal/web/ui/button.css";
|
||||||
@import "./internal/web/ui/card.css";
|
@import "./internal/web/ui/card.css";
|
||||||
@import "./internal/web/ui/badge.css";
|
@import "./internal/web/ui/badge.css";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue