xtablo-source/.planning/phases/14-auth-pages/14-PATTERNS.md
Arthur Belleville 522c071550
docs(14): create phase plan
Plan 14-01: Auth foundation (logo assets, auth.css, auth_components.templ, auth_layout.templ)
Plan 14-02: Page migration (auth_login.templ, auth_signup.templ → AuthLayout + ui.FormField + nav links)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-16 19:00:57 +02:00

22 KiB
Raw Blame History

Phase 14: Auth Pages - Pattern Map

Mapped: 2026-05-16 Files analyzed: 7 (5 templates/CSS + 2 static assets) Analogs found: 7 / 7


File Classification

New/Modified File Role Data Flow Closest Analog Match Quality
backend/templates/auth_layout.templ template/layout request-response backend/templates/layout.templ exact-role
backend/templates/auth_components.templ template/component static-render go-backend/internal/web/views/auth_components.templ exact
backend/templates/auth_login.templ template/page request-response backend/templates/auth_signup.templ (peer) exact
backend/templates/auth_signup.templ template/page request-response backend/templates/auth_login.templ (peer) exact
backend/internal/web/ui/auth.css stylesheet static-render go-backend/static/styles.css lines 10631510, 25332947 exact (extract)
backend/static/logo_dark.png static asset file-copy go-backend/static/logo_dark.png exact
backend/static/logo_white.png static asset file-copy go-backend/static/logo_white.png exact

Pattern Assignments

backend/templates/auth_layout.templ (layout, request-response)

Analog: backend/templates/layout.templ

Package and imports pattern (backend/templates/layout.templ lines 19):

package templates

import (
    "backend/internal/web/ui"
)

AuthLayout does NOT import backend/internal/auth — auth pages are always unauthenticated. No user *auth.User parameter.

HTML shell pattern (backend/templates/layout.templ lines 2559 adapted):

templ AuthLayout(title string, csrfToken string) {
    <!DOCTYPE html>
    <html lang="en">
        <head>
            <meta charset="utf-8"/>
            <meta name="viewport" content="width=device-width, initial-scale=1"/>
            <title>{ title }</title>
            <link rel="stylesheet" href="/static/tailwind.css"/>
        </head>
        <body>
            <div class="login-screen">
                @AnimatedBackground()
                <div class="card-wrap">
                    <div class="card-glow"></div>
                    <div class="auth-card-shell">
                        { children... }
                    </div>
                </div>
            </div>
            <script src="/static/htmx.min.js" defer></script>
        </body>
    </html>
}

Key deltas from Layout:

  • No <header>, no <footer>, no <main> wrapper
  • Body wraps in .login-screen (not min-h-screen bg-white)
  • Scripts: keep htmx.min.js; drop sortable.min.js and discussion-sse.js (not needed on auth pages)
  • csrfToken is threaded through because the page children (form fragments) use ui.CSRFField(csrfToken)
  • @AnimatedBackground() is called from auth_components.templ in the same package

CSRF threading note: csrfToken on AuthLayout is passed to child page templates (LoginPage, SignupPage) which forward it into their form fragments. The layout itself does not embed a CSRF field — only the forms do.


backend/templates/auth_components.templ (component, static-render)

Analog: go-backend/internal/web/views/auth_components.templ (port verbatim, with adaptations noted)

Package declaration:

package templates

(not package views — must match the backend package)

AnimatedBackground component (go-backend/internal/web/views/auth_components.templ lines 108158):

Port verbatim from go-backend. Two adaptations:

  1. Image src path: change /logo_dark.png/static/logo_dark.png (backend serves static files under /static/)
  2. Dark-mode images (light-only / dark-only pairs): Phase 14 uses logo_dark.png only — replace the two-image pattern with a single <img> per element (dark mode deferred per DEFERRED section). The light-only / dark-only CSS classes are defined in base.css lines 206213 but should not be emitted on images in Phase 14.
templ AnimatedBackground() {
    <div class="background-layer" aria-hidden="true">
        <div class="background-logo bg-01 animate-move-right-slow opacity-04">
            <img alt="Xtablo" class="logo-asset size-16 animate-spin-slow" src="/static/logo_dark.png"/>
        </div>
        <div class="background-logo bg-02 animate-move-right-medium opacity-03">
            <img alt="Xtablo" class="logo-asset size-12 animate-bounce-gentle" src="/static/logo_dark.png"/>
        </div>
        // ... all 35 elements following the same single-img pattern
        // See go-backend source lines 110156 for the full class list per element
    </div>
}

Full element list (go-backend lines 110156 — class assignments per slot):

Slot Outer classes Inner img classes
bg-01 animate-move-right-slow opacity-04 size-16 animate-spin-slow
bg-02 animate-move-right-medium opacity-03 size-12 animate-bounce-gentle
bg-03 animate-move-right-fast opacity-05 size-20 animate-pulse-gentle
bg-04 animate-move-right-slow opacity-02 size-14 animate-wiggle
bg-05 animate-move-right-medium opacity-03 size-18 animate-float-gentle
bg-06 animate-move-diagonal-1 opacity-03 size-10 animate-spin-reverse
bg-07 animate-move-diagonal-2 opacity-04 size-16 animate-scale-gentle
bg-08 animate-move-diagonal-3 opacity-02 size-12 animate-rotate-gentle
bg-09 animate-move-down-slow opacity-03 size-14 animate-bounce-soft
bg-10 animate-move-down-medium opacity-04 size-16 animate-sway
bg-11 animate-orbit-1 opacity-02 size-08 (no img animation)
bg-12 animate-orbit-2 opacity-03 size-10 (no img animation)
bg-13 animate-orbit-3 opacity-02 size-06 (no img animation)
bg-14 animate-orbit-4 opacity-03 size-12 animate-spin-fast
bg-15 animate-orbit-5 opacity-02 size-07 animate-pulse-fast
bg-16 animate-zigzag-1 opacity-04 size-14 animate-wobble
bg-17 animate-zigzag-2 opacity-03 size-11 animate-shake
bg-18 animate-zigzag-3 opacity-05 size-16 animate-bounce-crazy
bg-19 animate-spiral-1 opacity-03 size-09 animate-spin-wobble
bg-20 animate-spiral-2 opacity-04 size-13 animate-flip
bg-21 animate-float-random-1 opacity-02 size-08 animate-twirl
bg-22 animate-float-random-2 opacity-03 size-10 animate-dance
bg-23 animate-float-random-3 opacity-04 size-12 animate-jiggle
bg-24 animate-float-random-4 opacity-02 size-09 animate-vibrate
bg-25 animate-wave-1 opacity-03 size-11 animate-swing
bg-26 animate-wave-2 opacity-04 size-13 animate-pendulum
bg-27 animate-wave-3 opacity-02 size-10 animate-elastic
bg-28 animate-wave-4 opacity-05 size-15 animate-rubber
bg-29 animate-corner-shoot-1 opacity-03 size-12 animate-rocket
bg-30 animate-corner-shoot-2 opacity-04 size-14 animate-comet
bg-31 animate-corner-shoot-3 opacity-02 size-10 animate-meteor
bg-32 animate-corner-shoot-4 opacity-05 size-16 animate-blast
bg-33 animate-bounce-ball-1 opacity-04 size-08 animate-spin-bounce
bg-34 animate-bounce-ball-2 opacity-03 size-11 animate-flip-bounce
bg-35 animate-bounce-ball-3 opacity-05 size-13 animate-scale-bounce

GoogleButton component (go-backend/internal/web/views/auth_components.templ lines 89106):

Port structure verbatim; change label text to English per D-G03:

templ GoogleButton(href string, configured bool) {
    if configured {
        <a class="gsi-material-button" href={ templ.SafeURL(href) }>
            <div class="gsi-material-button-state"></div>
            <div class="gsi-material-button-content-wrapper">
                <div class="gsi-material-button-icon">
                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" aria-hidden="true">
                        <path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"></path>
                        <path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"></path>
                        <path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"></path>
                        <path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"></path>
                        <path fill="none" d="M0 0h48v48H0z"></path>
                    </svg>
                </div>
                <span class="gsi-material-button-contents">Sign in with Google</span>
                <span class="visually-hidden">Sign in with Google</span>
            </div>
        </a>
    } else {
        <button type="button" class="gsi-material-button" disabled aria-disabled="true">
            // same inner structure, same label
        </button>
    }
}

Note: go-backend GoogleButton is a <button> unconditionally. The backend already has the configured/unconfigured split via AuthProviderButtonControl — wire GoogleButton to accept (href string, configured bool) to preserve this logic.

AuthDivider component (go-backend/internal/web/views/auth_components.templ lines 8187):

templ AuthDivider() {
    <div class="divider-row">
        <div class="divider-line"></div>
        <span class="divider-pill">or</span>
        <div class="divider-line"></div>
    </div>
}

Change "Ou continuer avec" → "or" per D-G03 English convention.


backend/templates/auth_login.templ (page, request-response)

Analog: backend/templates/auth_login.templ (current — this file is being refactored)

Function signatures stay compatible with handlers (do not change):

templ LoginPage(form LoginForm, errs LoginErrors, csrfToken string, providers AuthProviderButtons) {
    @AuthLayout("Sign in to Xtablo", csrfToken) {
        <div class="auth-card-topbar"></div>
        <div class="brand-header">
            <img class="brand-logo" src="/static/logo_dark.png" alt="Xtablo"/>
        </div>
        <div class="title-group">
            <h1>Sign in to Xtablo</h1>
        </div>
        <div class="auth-body">
            @GoogleButton(providers.Google.StartURL, providers.Google.Configured)
            @AuthDivider()
            @LoginFormFragment(form, errs, csrfToken)
        </div>
    }
}

LoginFormFragment pattern (migrate raw inputs to @ui.FormField + @ui.Input):

Current pattern (lines 2875 of backend/templates/auth_login.templ) uses raw <input> with Tailwind classes. Replace with:

templ LoginFormFragment(form LoginForm, errs LoginErrors, csrfToken string) {
    <form
        id="login-form"
        method="POST"
        action="/login"
        hx-post="/login"
        hx-target="#login-form"
        hx-swap="outerHTML"
        class="login-form"
    >
        @ui.CSRFField(csrfToken)
        @GeneralError(errs.General)
        @ui.FormField(ui.FormFieldProps{
            Label: "Email address",
            For:   "email",
            Field: ui.Input(ui.InputProps{
                ID:          "email",
                Name:        "email",
                Type:        "email",
                Placeholder: "you@example.com",
                Value:       form.Email,
                Required:    true,
                Attrs:       templ.Attributes{"autocomplete": "email"},
            }),
            Error: errs.Email,
        })
        @ui.FormField(ui.FormFieldProps{
            Label: "Password",
            For:   "password",
            Field: ui.Input(ui.InputProps{
                ID:          "password",
                Name:        "password",
                Type:        "password",
                Placeholder: "Your password",
                Required:    true,
                Attrs:       templ.Attributes{"autocomplete": "current-password"},
            }),
            Error: errs.Password,
        })
        @ui.Button(ui.ButtonProps{
            Label:   "Sign in to Xtablo",
            Variant: ui.ButtonVariantDefault,
            Tone:    ui.ButtonToneSolid,
            Size:    ui.SizeMD,
            Type:    "submit",
        })
        <p class="signup-copy">
            Don't have an account?
            <a class="signup-link" href="/signup">Sign up</a>
        </p>
    </form>
}

Key changes from current:

  • class="space-y-5" on form → class="login-form" (auth.css drives layout)
  • Raw <input> elements → @ui.FormField + @ui.Input
  • @FieldError(errs.X) calls removed — errors wired through FormFieldProps.Error
  • Submit label: "Sign in" → "Sign in to Xtablo" (copywriting contract)
  • Nav link added below submit button inside the form element
  • HTMX attributes preserved exactly: hx-post="/login" hx-target="#login-form" hx-swap="outerHTML"
  • AuthProviderButtonsBlock component replaced by GoogleButton + AuthDivider called from the page template (not the fragment)

backend/templates/auth_signup.templ (page, request-response)

Analog: backend/templates/auth_login.templ (mirror of login pattern)

Function signature (unchanged for handler compatibility):

templ SignupPage(form SignupForm, errs SignupErrors, csrfToken string, providers AuthProviderButtons) {
    @AuthLayout("Create your account", csrfToken) {
        <div class="auth-card-topbar"></div>
        <div class="brand-header">
            <img class="brand-logo" src="/static/logo_dark.png" alt="Xtablo"/>
        </div>
        <div class="title-group">
            <h1>Create your account</h1>
        </div>
        <div class="auth-body">
            @GoogleButton(providers.Google.StartURL, providers.Google.Configured)
            @AuthDivider()
            @SignupFormFragment(form, errs, csrfToken)
        </div>
    }
}

SignupFormFragment pattern (mirror of login, differences noted):

templ SignupFormFragment(form SignupForm, errs SignupErrors, csrfToken string) {
    <form
        id="signup-form"
        method="POST"
        action="/signup"
        hx-post="/signup"
        hx-target="#signup-form"
        hx-swap="outerHTML"
        class="login-form"
    >
        @ui.CSRFField(csrfToken)
        @GeneralError(errs.General)
        @ui.FormField(ui.FormFieldProps{
            Label: "Email address",
            For:   "email",
            Field: ui.Input(ui.InputProps{
                ID:          "email",
                Name:        "email",
                Type:        "email",
                Placeholder: "you@example.com",
                Value:       form.Email,
                Required:    true,
                Attrs:       templ.Attributes{"autocomplete": "email"},
            }),
            Error: errs.Email,
        })
        @ui.FormField(ui.FormFieldProps{
            Label: "Password",
            For:   "password",
            Field: ui.Input(ui.InputProps{
                ID:          "password",
                Name:        "password",
                Type:        "password",
                Placeholder: "12 characters minimum",   // differs from login
                Required:    true,
                Attrs:       templ.Attributes{"autocomplete": "new-password"},  // differs from login
            }),
            Error: errs.Password,
        })
        @ui.Button(ui.ButtonProps{
            Label:   "Create account",   // differs from login
            Variant: ui.ButtonVariantDefault,
            Tone:    ui.ButtonToneSolid,
            Size:    ui.SizeMD,
            Type:    "submit",
        })
        <p class="signup-copy">
            Already have an account?
            <a class="signup-link" href="/login">Sign in</a>
        </p>
    </form>
}

backend/internal/web/ui/auth.css (stylesheet, replace entirely)

Analog: go-backend/static/styles.css (extract auth-relevant sections only — do NOT import the file)

This file replaces the existing minimal auth.css (62 lines). The new file is a full extraction from go-backend's styles.css. The existing .auth-provider-stack, .auth-provider-button, .auth-provider-separator rules should be kept at the bottom for any non-Google controls.

Section map — what to extract and from where:

CSS section go-backend source lines Notes
.login-screen 10631079 Two declarations; merge into one
.background-layer 10811086
.background-logo 10881090
.logo-asset 10921097
.size-06 through .size-20 10991111 13 size utilities
.opacity-02 through .opacity-05 11131116 4 opacity utilities
.bg-01 through .bg-35 11181155 35 position rules
.card-wrap 11571163
.card-glow 11651172
.auth-card-shell 11741182 padding: 1.25rem ported verbatim
.auth-card-topbar 11841189
.brand-header 12371241
.brand-logo 12431248
.title-group and .title-group h1 12501259
.auth-body 12791284
.login-form 12861293 Keep for HTMX swap compat
.divider-row 13561363
.divider-line 13651368
.divider-pill 13701379
.signup-copy 13811386
.signup-link 13881402 Includes hover state
.status-slot, .status-banner, .status-success, .status-error 14041425
.gsi-material-button and all sub-classes 14271509 Full block verbatim
All @keyframes definitions 25332882 ~350 lines
All .animate-* utility classes 28832947 ~65 lines

Critical CSS values to copy verbatim (go-backend lines 11741182):

.auth-card-shell {
  backdrop-filter: blur(12px);
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 1rem;
  box-shadow: var(--shadow-auth-card);
  padding: 1.25rem;    /* NOT a design token — ported as-is */
  position: relative;
}

Tokens already defined in base.css (no need to redefine):

  • --gradient-shell (line 139)
  • --gradient-card-glow (line 143)
  • --shadow-auth-card (line 127)
  • --color-surface-default, --color-border-google, --color-text-google (lines 20, 45, 14)
  • --overlay-google-state (line 126)
  • --shadow-google-button (lines 135137)
  • --card, --border, --muted-foreground, --foreground, --accent, --background (legacy alias block, lines 161171)

backend/static/logo_dark.png and backend/static/logo_white.png (static assets, file-copy)

Source: go-backend/static/logo_dark.png and go-backend/static/logo_white.png

Action: Binary file copy. No transformation.

cp go-backend/static/logo_dark.png backend/static/logo_dark.png
cp go-backend/static/logo_white.png backend/static/logo_white.png

AnimatedBackground references /static/logo_dark.png — this path must resolve before templ generate is run and before the server starts. Copy the asset before any template work.


Shared Patterns

templ package declaration

Apply to: auth_layout.templ, auth_components.templ

package templates

All files in backend/templates/ use this package. Components in auth_components.templ are called with @AnimatedBackground(), @AuthDivider(), @GoogleButton(...) from sibling files in the same package — no import needed.

ui package import

Source: backend/templates/auth_login.templ line 3; backend/internal/web/ui/input.templ, form_field.templ Apply to: auth_login.templ, auth_signup.templ

import "backend/internal/web/ui"

@ui.FormField, @ui.Input, @ui.Button, ui.CSRFField are all called with this import.

CSRF field threading

Source: backend/templates/layout.templ line 41; backend/templates/auth_login.templ line 38 Apply to: auth_layout.templ, auth_login.templ, auth_signup.templ

// AuthLayout receives csrfToken and passes it to children via { children... }
// The page template (LoginPage/SignupPage) forwards it to FormFragment
// The fragment embeds it: @ui.CSRFField(csrfToken)

HTMX swap pattern

Source: backend/templates/auth_login.templ lines 2936 Apply to: LoginFormFragment, SignupFormFragment (preserve exactly — no changes)

hx-post="/login"
hx-target="#login-form"
hx-swap="outerHTML"
// form id must match hx-target: id="login-form" / id="signup-form"

templ.SafeURL for external hrefs

Source: go-backend/internal/web/views/auth_components.templ line 69 Apply to: GoogleButton (when rendering the <a> variant), nav links

href={ templ.SafeURL(href) }
// Use for any runtime-constructed URL; static strings like "/signup" can be plain href="/signup"

CSS design token usage

Source: backend/internal/web/ui/base.css Apply to: auth.css (all CSS rules)

  • Use var(--card) for glassmorphism card background (= rgba(255,255,255,0.8))
  • Use var(--border) for card border
  • Use var(--gradient-shell) for .login-screen background
  • Use var(--gradient-card-glow) for .card-glow
  • Use var(--shadow-auth-card) for card box-shadow
  • Use var(--muted-foreground) for .signup-copy, .divider-pill
  • Use var(--foreground) for .signup-link base color
  • Use var(--accent) for .signup-link:hover background
  • Use var(--background) for .divider-pill background and input backgrounds
  • Do NOT hardcode colors that have token equivalents

No Analog Found

No files in this phase are without an analog. All patterns have direct source counterparts.


Metadata

Analog search scope:

  • backend/templates/ — current backend templates
  • backend/internal/web/ui/ — design system components and CSS
  • go-backend/internal/web/views/ — reference implementation templates
  • go-backend/static/styles.css — reference CSS source

Files scanned: 8 source files read in full or in targeted sections Pattern extraction date: 2026-05-16


Implementation Order

Execute in this order to avoid broken references:

  1. Copy static assets (logo_dark.png, logo_white.png) — required before template renders
  2. Write auth_components.templAnimatedBackground, GoogleButton, AuthDivider components
  3. Write auth_layout.templ — calls @AnimatedBackground() from step 2
  4. Replace auth.css — all CSS classes must exist before browser renders
  5. Update auth_login.templ — switches to @AuthLayout, migrates inputs
  6. Update auth_signup.templ — same changes as login