go-htmx-gsd #1

Merged
arthur merged 558 commits from go-htmx-gsd into main 2026-05-23 15:16:44 +00:00
7 changed files with 564 additions and 21 deletions
Showing only changes of commit ab030dbe71 - Show all commits

View file

@ -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

View 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;
}

View file

@ -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;
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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))
}

View file

@ -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";