From a30a6f90886e6dbd106c69b70571fed765effe18 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sat, 16 May 2026 13:55:30 +0200 Subject: [PATCH] feat(13-02): replace CSS files, migrate card.templ to typed API, update all template hardcodes - button.css: replaced with go-backend multi-class selector version + ghost variant rules - badge.css: replaced with go-backend pill-shape version + primary variant - card.css: replaced with go-backend token-based header/body/footer version - card.templ: migrated from children passthrough to typed CardProps{Header/Body/Footer} - ui_test.go: rewrote TestCard_RendersChildren -> TestCard_RendersTypedRegions; added TestBadge_PrimaryVariant; added textComponent helper + io import - auth_login.templ, auth_signup.templ: migrated Card usage to typed CardProps API - tablos.templ: migrated TabloCard to typed CardProps API with extracted tabloCardBody - planning.templ, tasks.templ, events.templ, etapes.templ: all compound button class strings updated to multi-class pattern - go test ./... passes (all packages green) - just generate succeeds --- backend/internal/web/ui/badge.css | 42 +++-- backend/internal/web/ui/button.css | 276 +++++++++++++++------------- backend/internal/web/ui/card.css | 31 +++- backend/internal/web/ui/card.templ | 27 ++- backend/internal/web/ui/ui_test.go | 54 ++++-- backend/templates/auth_login.templ | 16 +- backend/templates/auth_signup.templ | 16 +- backend/templates/etapes.templ | 10 +- backend/templates/events.templ | 4 +- backend/templates/planning.templ | 8 +- backend/templates/tablos.templ | 52 +++--- backend/templates/tasks.templ | 4 +- 12 files changed, 318 insertions(+), 222 deletions(-) diff --git a/backend/internal/web/ui/badge.css b/backend/internal/web/ui/badge.css index fc03a02..ab9b885 100644 --- a/backend/internal/web/ui/badge.css +++ b/backend/internal/web/ui/badge.css @@ -1,28 +1,42 @@ -/* badge.css — Phase 1 ships info / success / danger. - * `warning` is declared in variants.go for forward compatibility but has no - * CSS rule in Phase 1 — it lands when a page first needs it. - */ +/* badge.css — pill-shape badges ported from go-backend. */ .ui-badge { - display: inline-block; - padding: 0.125rem 0.5rem; + border: 1px solid transparent; + border-radius: 999px; + display: inline-flex; font-size: 0.75rem; font-weight: 600; - border-radius: 0.25rem; - line-height: 1.25; + line-height: 1.2; + padding: 0.3rem 0.75rem; } .ui-badge-info { - background-color: #dbeafe; - color: #1d4ed8; + background: var(--color-status-info-soft-bg); + border-color: var(--color-status-info-soft-border); + color: var(--color-status-info-foreground); +} + +.ui-badge-warning { + background: var(--color-status-warning-soft-bg); + border-color: var(--color-status-warning-soft-border); + color: var(--color-status-warning-foreground); } .ui-badge-success { - background-color: #dcfce7; - color: #16a34a; + background: var(--color-status-success-soft-bg); + border-color: var(--color-status-success-soft-border); + color: var(--color-status-success-foreground); } .ui-badge-danger { - background-color: #fee2e2; - color: #dc2626; + background: var(--color-status-danger-soft-bg); + border-color: var(--color-status-danger-soft-border); + color: var(--color-status-danger-foreground); +} + +/* Primary variant — new in Phase 13, D-CA02 */ +.ui-badge-primary { + background: var(--color-surface-brand-soft); + border-color: rgba(128, 78, 236, 0.3); + color: var(--color-text-brand); } diff --git a/backend/internal/web/ui/button.css b/backend/internal/web/ui/button.css index f165417..e8f3b40 100644 --- a/backend/internal/web/ui/button.css +++ b/backend/internal/web/ui/button.css @@ -1,180 +1,194 @@ -/* button.css — Phase 1 ships only solid / default / md. - * Codex concern #7: no CSS nesting (`&:hover`). All pseudo-class rules are - * declared as top-level selectors so the file is portable across every - * Tailwind v4 standalone processing mode. +/* button.css — multi-class selector pattern ported from go-backend. + * No CSS nesting: all pseudo-class rules are top-level selectors. */ .ui-button { - display: inline-flex; - align-items: center; - font-family: inherit; - font-size: 1rem; - line-height: 1.25; - border: 1px solid transparent; - cursor: pointer; - user-select: none; - text-decoration: none; + align-items: center; + border: 0; + border-radius: 0.35rem; + cursor: pointer; + display: inline-flex; + font-weight: 600; + gap: 0.5rem; + justify-content: center; + line-height: 1; + min-height: 44px; + text-decoration: none; + transition: + background-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease, + opacity 0.2s ease; +} + +.ui-button-icon, +.ui-button-icon svg { + height: 1rem; + width: 1rem; } .ui-button:focus-visible { - outline: 2px solid #1d4ed8; - outline-offset: 2px; + box-shadow: 0 0 0 3px var(--color-focus-ring); + outline: none; } -.ui-button.htmx-request { - opacity: 0.6; - pointer-events: none; +.ui-button-sm { + font-size: 0.875rem; + min-height: 40px; + padding: 0.625rem 0.9rem; } -.ui-button-solid-default-md { - display: inline-flex; - align-items: center; - border-radius: 0.375rem; - background-color: #2563eb; - padding: 0.5rem 1rem; - font-size: 1rem; - font-weight: 600; - color: #ffffff; +.ui-button-md { + font-size: 0.95rem; + padding: 0.7rem 1rem; } -.ui-button-solid-default-md:hover { - background-color: #1d4ed8; +.ui-button-lg { + font-size: 1rem; + padding: 0.82rem 1.15rem; } -.ui-button-solid-default-md:focus-visible { - outline: 2px solid #1d4ed8; - outline-offset: 2px; +.ui-button-solid.ui-button-default { + background: var(--color-brand-primary); + color: var(--color-brand-foreground, var(--color-text-inverse)); } -/* Phase 3: Danger (solid) and Neutral-soft button variants. */ -/* Codex concern #7: no CSS nesting — all pseudo-class rules are top-level. */ - -.ui-button-solid-danger-md { - display: inline-flex; - align-items: center; - border-radius: 0.375rem; - background-color: #b91c1c; - padding: 0.5rem 1rem; - font-size: 1rem; - font-weight: 600; - color: #ffffff; - min-height: 44px; +.ui-button-solid.ui-button-default:hover { + background: var(--color-brand-primary-hover); } -.ui-button-solid-danger-md:hover { - background-color: #991b1b; +.ui-button-solid.ui-button-default:active { + background: var(--color-brand-primary-active); } -.ui-button-solid-danger-md:focus-visible { - outline: 2px solid #b91c1c; - outline-offset: 2px; +.ui-button-solid.ui-button-neutral { + background: var(--color-surface-muted); + color: var(--color-text-primary); } -.ui-button-soft-neutral-md { - display: inline-flex; - align-items: center; - border-radius: 0.375rem; - background-color: #f1f5f9; - padding: 0.5rem 1rem; - font-size: 1rem; - font-weight: 400; - color: #334155; - border: 1px solid #e2e8f0; - min-height: 44px; +.ui-button-solid.ui-button-neutral:hover { + background: var(--color-surface-muted-hover); } -.ui-button-soft-neutral-md:hover { - background-color: #e2e8f0; +.ui-button-solid.ui-button-neutral:active { + background: var(--color-surface-muted-active); } -.ui-button-soft-neutral-md:focus-visible { - outline: 2px solid #64748b; - outline-offset: 2px; +.ui-button-solid.ui-button-warning { + background: var(--color-status-warning-strong); + color: var(--color-status-warning-strong-foreground); } -/* Phase 4: Soft-danger button variant for task delete actions. */ -/* Codex concern #7: no CSS nesting — all pseudo-class rules are top-level. */ - -.ui-button-soft-danger-md { - display: inline-flex; - align-items: center; - border-radius: 0.375rem; - background-color: #fee2e2; - padding: 0.5rem 1rem; - font-size: 1rem; - font-weight: 600; - color: #b91c1c; - border: 1px solid #fecaca; - min-height: 44px; +.ui-button-solid.ui-button-warning:hover { + background: var(--color-status-warning-strong-hover); } -.ui-button-soft-danger-md:hover { - background-color: #fecaca; +.ui-button-solid.ui-button-warning:active { + background: var(--color-status-warning-strong-active); } -.ui-button-soft-danger-md:focus-visible { - outline: 2px solid #b91c1c; - outline-offset: 2px; +.ui-button-solid.ui-button-success { + background: var(--color-status-success-strong); + color: var(--color-status-success-strong-foreground); } -/* Phase 8: neutral provider controls for social sign-in. */ - -.auth-provider-stack { - display: grid; - gap: 8px; - margin-bottom: 16px; +.ui-button-solid.ui-button-success:hover { + background: var(--color-status-success-strong-hover); } -.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; +.ui-button-solid.ui-button-success:active { + background: var(--color-status-success-strong-active); } -.auth-provider-button:hover { - background-color: #f8fafc; +.ui-button-solid.ui-button-danger { + background: var(--color-status-danger-strong); + color: var(--color-status-danger-strong-foreground); } -.auth-provider-button:focus-visible { - outline: 2px solid #2563eb; - outline-offset: 2px; +.ui-button-solid.ui-button-danger:hover { + background: var(--color-status-danger-strong-hover); } -.auth-provider-button-disabled, -.auth-provider-button-disabled:hover { - background-color: #f1f5f9; - color: #94a3b8; - cursor: not-allowed; +.ui-button-solid.ui-button-danger:active { + background: var(--color-status-danger-strong-active); } -.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; +.ui-button-soft.ui-button-default { + background: var(--color-surface-brand-soft); + color: var(--color-brand-primary-hover); } -.auth-provider-separator span { - height: 1px; - background-color: #e2e8f0; +.ui-button-soft.ui-button-default:hover { + background: var(--color-surface-brand-soft-hover); } -.auth-provider-separator em { - font-style: normal; +.ui-button-soft.ui-button-default:active { + background: var(--color-surface-brand-soft-active); +} + +.ui-button-soft.ui-button-warning { + background: var(--color-status-warning-soft-bg); + color: var(--color-status-warning-soft-foreground-strong); +} + +.ui-button-soft.ui-button-warning:hover { + background: var(--color-status-warning-soft-bg-hover); +} + +.ui-button-soft.ui-button-warning:active { + background: var(--color-status-warning-soft-bg-active); +} + +.ui-button-soft.ui-button-success { + background: var(--color-status-success-soft-bg); + color: var(--color-status-success-soft-foreground-strong); +} + +.ui-button-soft.ui-button-success:hover { + background: var(--color-status-success-soft-bg-hover); +} + +.ui-button-soft.ui-button-success:active { + background: var(--color-status-success-soft-bg-active); +} + +.ui-button-soft.ui-button-danger { + background: var(--color-status-danger-soft-bg-alt); + color: var(--color-status-danger-soft-foreground-strong); +} + +.ui-button-soft.ui-button-danger:hover { + background: var(--color-status-danger-soft-bg-hover); +} + +.ui-button-soft.ui-button-danger:active { + background: var(--color-status-danger-soft-bg-active); +} + +/* Ghost variant — new in Phase 13, D-CA01 */ +.ui-button-ghost { + background: transparent; + color: var(--color-brand-primary); +} + +.ui-button-ghost:hover { + background: var(--color-surface-brand-soft); +} + +.ui-button-ghost:focus-visible { + box-shadow: 0 0 0 3px var(--color-focus-ring); + outline: none; +} + +.ui-button-soft.ui-button-neutral { + background: var(--color-surface-muted); + color: var(--color-text-secondary); +} + +.ui-button-soft.ui-button-neutral:hover { + background: var(--color-surface-muted-hover); +} + +.ui-button-soft.ui-button-neutral:active { + background: var(--color-surface-muted-active); } diff --git a/backend/internal/web/ui/card.css b/backend/internal/web/ui/card.css index 74dcba6..a3833d3 100644 --- a/backend/internal/web/ui/card.css +++ b/backend/internal/web/ui/card.css @@ -1,8 +1,29 @@ -/* card.css — slate-50 panel with slate-200 border. */ +/* card.css — token-based card with header/body/footer regions ported from go-backend. */ .ui-card { - border-radius: 0.5rem; - border: 1px solid #e2e8f0; - background-color: #f8fafc; - padding: 1.5rem; + background: var(--color-surface-default); + border: 1px solid var(--color-border-default); + border-radius: 1rem; + box-shadow: var(--shadow-surface-md); +} + +.ui-card-header, +.ui-card-body, +.ui-card-footer { + padding: 1.25rem 1.5rem; +} + +.ui-card-header, +.ui-card-footer { + border-color: var(--color-border-default); +} + +.ui-card-header { + border-bottom-style: solid; + border-bottom-width: 1px; +} + +.ui-card-footer { + border-top-style: solid; + border-top-width: 1px; } diff --git a/backend/internal/web/ui/card.templ b/backend/internal/web/ui/card.templ index 29893a9..6615653 100644 --- a/backend/internal/web/ui/card.templ +++ b/backend/internal/web/ui/card.templ @@ -1,11 +1,24 @@ package ui -// Card is a slate-50 panel with a slate-200 border. Children are rendered -// inside via templ's child-content syntax: `@ui.Card(nil) {

...

}`. -// -// `attrs` is a pass-through for arbitrary attributes (id, data-*, hx-*). -templ Card(attrs templ.Attributes) { -
- { children... } +// CardProps is the input to the Card templ component. +// Header, Body, and Footer are optional typed regions — nil fields are omitted +// from the rendered output (no empty wrapper divs). +type CardProps struct { + Header templ.Component + Body templ.Component + Footer templ.Component +} + +templ Card(props CardProps) { +
+ if props.Header != nil { +
@props.Header
+ } + if props.Body != nil { +
@props.Body
+ } + if props.Footer != nil { + + }
} diff --git a/backend/internal/web/ui/ui_test.go b/backend/internal/web/ui/ui_test.go index fa4d1e0..4125007 100644 --- a/backend/internal/web/ui/ui_test.go +++ b/backend/internal/web/ui/ui_test.go @@ -3,6 +3,7 @@ package ui import ( "bytes" "context" + "io" "strings" "testing" @@ -67,20 +68,6 @@ func TestButton_ExplicitTypeSubmit(t *testing.T) { } } -// Card injects children via templ.WithChildren. The test feeds a raw child -// component through context and asserts the rendered wrapper + child content. -func TestCard_RendersChildren(t *testing.T) { - child := templ.Raw("

x

") - ctx := templ.WithChildren(context.Background(), child) - out := render(t, ctx, Card(nil)) - - if !strings.Contains(out, `
; got: %s", out) - } - if !strings.Contains(out, "

x

") { - t.Errorf("output missing child markup; got: %s", out) - } -} func TestBadge_InfoVariant(t *testing.T) { out := render(t, context.Background(), Badge(BadgeProps{Label: "OK", Variant: BadgeVariantInfo})) @@ -200,3 +187,42 @@ func TestSpaceYClass_LG(t *testing.T) { t.Errorf("SpaceYClass(SpacingStepLG) = %q; want %q", got, want) } } + +// textComponent is a test helper that wraps a raw string as a templ.Component, +// used for asserting typed CardProps fields (Header/Body/Footer). +func textComponent(text string) templ.Component { + return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error { + _, err := w.Write([]byte(text)) + return err + }) +} + +// Phase 13 Plan 02 — typed Card API and Badge primary variant tests (TDD RED) + +func TestCard_RendersTypedRegions(t *testing.T) { + out := render(t, context.Background(), Card(CardProps{ + Header: textComponent("header"), + Body: textComponent("body"), + })) + for _, want := range []string{ + `ui-card-header`, + `header`, + `ui-card-body`, + `body`, + } { + if !strings.Contains(out, want) { + t.Fatalf("expected %q in %q", want, out) + } + } + // nil footer must not emit ui-card-footer + if strings.Contains(out, "ui-card-footer") { + t.Errorf("expected no ui-card-footer when Footer is nil; got: %s", out) + } +} + +func TestBadge_PrimaryVariant(t *testing.T) { + out := render(t, context.Background(), Badge(BadgeProps{Label: "new", Variant: BadgeVariantPrimary})) + if !strings.Contains(out, "ui-badge-primary") { + t.Errorf("expected ui-badge-primary in output; got: %s", out) + } +} diff --git a/backend/templates/auth_login.templ b/backend/templates/auth_login.templ index accb1b8..5f10a02 100644 --- a/backend/templates/auth_login.templ +++ b/backend/templates/auth_login.templ @@ -8,17 +8,19 @@ import "backend/internal/web/ui" templ LoginPage(form LoginForm, errs LoginErrors, csrfToken string, providers AuthProviderButtons) { @Layout("Sign in", nil, csrfToken) {
- @ui.Card(nil) { -
-

Sign in to your account

- @AuthProviderButtonsBlock(providers) - @LoginFormFragment(form, errs, csrfToken) -
- } + @ui.Card(ui.CardProps{Body: loginCardBody(form, errs, csrfToken, providers)})
} } +templ loginCardBody(form LoginForm, errs LoginErrors, csrfToken string, providers AuthProviderButtons) { +
+

Sign in to your account

+ @AuthProviderButtonsBlock(providers) + @LoginFormFragment(form, errs, csrfToken) +
+} + // LoginFormFragment is the bare form used for HTMX swaps. // hx-post targets this component itself so the form can be replaced inline // on validation failure (D-19, D-20). diff --git a/backend/templates/auth_signup.templ b/backend/templates/auth_signup.templ index 61116dc..05e8aec 100644 --- a/backend/templates/auth_signup.templ +++ b/backend/templates/auth_signup.templ @@ -8,17 +8,19 @@ import "backend/internal/web/ui" templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string, providers AuthProviderButtons) { @Layout("Sign up", nil, csrfToken) {
- @ui.Card(nil) { -
-

Create your account

- @AuthProviderButtonsBlock(providers) - @SignupFormFragment(form, errs, csrfToken) -
- } + @ui.Card(ui.CardProps{Body: signupCardBody(form, errs, csrfToken, providers)})
} } +templ signupCardBody(form SignupForm, errs SignupErrors, csrfToken string, providers AuthProviderButtons) { +
+

Create your account

+ @AuthProviderButtonsBlock(providers) + @SignupFormFragment(form, errs, csrfToken) +
+} + // SignupFormFragment is the bare form used for HTMX swaps. // hx-post targets this component itself so the form can be replaced inline // on validation failure (D-19, D-25). diff --git a/backend/templates/etapes.templ b/backend/templates/etapes.templ index 9bf54e1..998b806 100644 --- a/backend/templates/etapes.templ +++ b/backend/templates/etapes.templ @@ -54,7 +54,7 @@ templ EtapeStrip(tabloID uuid.UUID, etapes []sqlc.Etape, counts EtapeTaskCounts, + } if index < len(etapes)-1 { @@ -83,14 +83,14 @@ templ EtapeStrip(tabloID uuid.UUID, etapes []sqlc.Etape, counts EtapeTaskCounts, for _, id := range etapeReorderIDs(etapes, index, 1) { } - + } }