diff --git a/.planning/phases/13-design-system-foundation/13-01-SUMMARY.md b/.planning/phases/13-design-system-foundation/13-01-SUMMARY.md new file mode 100644 index 0000000..24b01d2 --- /dev/null +++ b/.planning/phases/13-design-system-foundation/13-01-SUMMARY.md @@ -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 diff --git a/backend/internal/web/ui/auth.css b/backend/internal/web/ui/auth.css new file mode 100644 index 0000000..dc85493 --- /dev/null +++ b/backend/internal/web/ui/auth.css @@ -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; +} diff --git a/backend/internal/web/ui/base.css b/backend/internal/web/ui/base.css index 6f17db7..6c6df95 100644 --- a/backend/internal/web/ui/base.css +++ b/backend/internal/web/ui/base.css @@ -1,28 +1,223 @@ -/* base.css — global resets and accessibility floor for the design system. - * Plain CSS only (no @apply, no nesting) so the file is consumable by both - * Tailwind v4 standalone (with @source scanning Go files) and any other - * downstream CSS pipeline. - */ +:root { + /* Text */ + --color-text-primary: hsl(0 0% 9%); + --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; -*, -*::before, -*::after { + /* Surfaces */ + --color-surface-page: hsl(0 0% 100%); + --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; } -html { - -webkit-text-size-adjust: 100%; - text-size-adjust: 100%; +html, +body { + margin: 0; + min-height: 100%; } body { - margin: 0; - font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; - color: #0f172a; - background-color: #ffffff; + background: var(--background); + color: var(--foreground); + font-family: + ui-sans-serif, + system-ui, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; } -:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; +a { + color: inherit; + 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; } diff --git a/backend/internal/web/ui/helpers.go b/backend/internal/web/ui/helpers.go index 7afee74..3177f01 100644 --- a/backend/internal/web/ui/helpers.go +++ b/backend/internal/web/ui/helpers.go @@ -1,6 +1,10 @@ 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, // 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 } + +// 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) +} diff --git a/backend/internal/web/ui/ui_test.go b/backend/internal/web/ui/ui_test.go index 85d8465..40cb54f 100644 --- a/backend/internal/web/ui/ui_test.go +++ b/backend/internal/web/ui/ui_test.go @@ -130,3 +130,67 @@ func TestNormalizers_ZeroValueDefaults(t *testing.T) { 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) + } +} diff --git a/backend/internal/web/ui/variants.go b/backend/internal/web/ui/variants.go index 13cd8bf..e961f27 100644 --- a/backend/internal/web/ui/variants.go +++ b/backend/internal/web/ui/variants.go @@ -20,6 +20,7 @@ const ( ButtonVariantWarning ButtonVariant = "warning" ButtonVariantSuccess ButtonVariant = "success" ButtonVariantDanger ButtonVariant = "danger" + ButtonVariantGhost ButtonVariant = "ghost" ) // ButtonTone is the visual-weight enum for Button (solid vs. soft). @@ -38,6 +39,36 @@ const ( BadgeVariantWarning BadgeVariant = "warning" BadgeVariantSuccess BadgeVariant = "success" 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 @@ -55,7 +86,7 @@ func NormalizedSize(size Size) Size { // the zero value and any value not in the declared set. func NormalizedButtonVariant(variant ButtonVariant) ButtonVariant { switch variant { - case ButtonVariantNeutral, ButtonVariantWarning, ButtonVariantSuccess, ButtonVariantDanger: + case ButtonVariantNeutral, ButtonVariantWarning, ButtonVariantSuccess, ButtonVariantDanger, ButtonVariantGhost: return variant default: return ButtonVariantDefault @@ -77,13 +108,46 @@ func NormalizedButtonTone(tone ButtonTone) ButtonTone { // zero value and any value not in the declared set. func NormalizedBadgeVariant(variant BadgeVariant) BadgeVariant { switch variant { - case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger: + case BadgeVariantWarning, BadgeVariantSuccess, BadgeVariantDanger, BadgeVariantPrimary: return variant default: 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 // are normalized before assembly so callers can pass zero values safely. // @@ -103,3 +167,29 @@ func BadgeClass(variant BadgeVariant) string { v := NormalizedBadgeVariant(variant) 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)) +} diff --git a/backend/tailwind.input.css b/backend/tailwind.input.css index f034efd..c4d4ac6 100644 --- a/backend/tailwind.input.css +++ b/backend/tailwind.input.css @@ -5,6 +5,7 @@ @source "./internal/web/**/*.go"; @import "./internal/web/ui/base.css"; +@import "./internal/web/ui/auth.css"; @import "./internal/web/ui/button.css"; @import "./internal/web/ui/card.css"; @import "./internal/web/ui/badge.css";