Skip to content

feat(web-ui): templ + Datastar UI with en/ja parity pages, served by the server - #6

Merged
jayleaton merged 10 commits into
mainfrom
feat/web-ui
Oct 22, 2026
Merged

jayleaton merged 10 commits into
mainfrom
feat/web-ui

Conversation

@jayleaton

@jayleaton jayleaton commented Oct 22, 2026 •

Copy link
Copy Markdown
Owner

Problem

The Go template has a server, JSON API and auth, but no web UI: server.Deps.UI is nil, so every page of the original Next.js app (home, why, login, signup, forgot/reset password, verify email + success, dashboard) is missing.

How

Adds the HTML transport as apps/web/ui: server-rendered templ pages, Datastar 1.0.4 over SSE, Tailwind v4. The server mounts it in cmd/server as UI: ui.New(ui.Deps{Auth: authn, Users: users, ...}).Handler(); nothing else in internal/* changes. make css rebuilds the committed Tailwind output. make generate and the CI generated-code check already run templ generate.

apps/web/ui
├── ui (deps.go, ui.go, handlers.go)  ui.New(Deps).Routes(mux) / Handler(); Auth + Users interfaces
│     *auth.Auth and *services.UserService satisfy them as is (compile-time asserted)
├── view/      layout shell (header: logout, theme, language), inputs, alerts, skeletons
├── welcome/   template welcome + /why (behind config.Features.TemplateWelcome, deletable)
├── i18n/      en/ja messages ported from the original (keys only added), cookie > Accept-Language
├── routes/    the UI's own action endpoints; pages use config.AppRoutes
├── static/    embedded app.css (Tailwind standalone CLI v4.3.3, checksum-verified) + datastar-1.0.4.js (SHA-256 pinned)
├── uifake/    in-memory Auth/Users for tests and the preview
└── cmd/preview  go run ./apps/web/ui/cmd/preview -> every page with fakes on 0.0.0.0:3001
  • Forms (login, signup, forgot, reset, resend verification, settings, delete account) are real <form>s with data-on:submit="@post(..., {contentType: 'form'})". A Datastar request gets patched fragments or a redirect over SSE; a plain post (no JS) gets the full page or a 303 from the same handler. data-indicator drives the loading labels, replacing loading.tsx.
  • Server push example: GET /dashboard/live streams the clock and session expiry every second, and patches the account card when the user changes in another tab. The notice appears only in the other tabs.
  • Layering: pages link through config.AppRoutes and read config.Features. The settings form is validated with validate.UpdateUserSettings and calls UpdateUserSettings / DeleteAllUserData on the service. Auth types and auth.Err* are used directly. The package imports neither pgx nor packages/database.
  • Client JS: only Datastar attributes. The theme is a cookie, and the server renders <html class>, so there is no flash.

Real server (make db-up && make migrate && make dev, headless Chromium, real auth + Postgres)

  • Sign up and sign in:
    • Sign up lands on the dashboard; dev has no email verification.
    • The live card connects and ticks: 07:13:38, then 07:13:42.
    • Signing in again shows the saved name.
  • Name save and logout:
    • The save shows "Settings saved." and patches the account card.
    • Postgres has name = 'Real User Renamed' for the account.
    • Logout lands on /login and leaves 0 sessions in Postgres.
    • A wrong password shows "Invalid credentials".
  • Language: switching to ja gives /login with lang="ja" and the heading 「アカウントにサインイン」.
  • Smoke checks: /healthz returns {"data":{"status":"ok"}}, and /, /login, /signup, /why, /static/app.css return 200.

Checks (on 2fd7510 + this branch)

  • make ci: exit 0. Build, check-generated (templ + sqlc, no diff), gofmt + golangci-lint 0 issues, lint-layering all ok (incl. apps/web/ui must not import packages/database), go test -race ./... all packages ok.
  • go test -race -count=1 ./apps/web/ui/...: 66 tests pass, 0 fail.
    • Handler tests (httptest + fakes): status codes, SSE fragments, cookies, cross-origin rejection, the no-JS fallback, and the live stream over a real connection.
    • templ render tests for every page/state in en and ja, failing on any untranslated key.
    • A test that fails when a locale lacks a key used in templates. Mutation-checked: deleting one ja key fails 3 tests.
    • The Datastar pin is checked by hash.
  • Every commit builds and vets on its own (git rebase --exec).
  • Real headless browser (@playwright/cli 0.1.22, Chromium) against the preview:
    • login error and success; signup; email-taken
    • forgot and reset (mismatch, success, reused link)
    • verify flow with the 5-minute resend countdown
    • settings save (posts only tab,name; email read-only)
    • cross-tab live update; delete account; logout
    • language switcher; theme persisted across reload
    • header right edge = content right edge = 1192px at 1280px wide

Evidence

Before: main has no UI (server.Deps.UI is nil).
After: 42 screenshots, each page/state in en and ja, at ~/Documents/spark-work/zig-dsv41-scratch/web-ui-shots/ on the dev box (local paths, not uploaded):

# state # state
01 home (welcome) 12 reset password done
02 why 13 reset password invalid link
03 home (welcome off) 14 verify email
04 login 15 verify email resent (countdown)
05 login error 16 verify success page, failed state
06 language menu 17 dashboard
07 signup 18 dashboard settings saved
08 forgot password 19 dashboard delete confirm
09 forgot password sent 20 dashboard dark
10 reset password 21 login dark
11 reset password mismatch

Files: en-NN-<state>.png and ja-NN-<state>.png. Real-server shots: real-01-dashboard-after-signup.png, real-02-settings-saved.png, real-03-dashboard-after-signin.png, real-04-login-ja.png. The original app was compared by its markup (classes ported one-to-one), not run side by side.

Choices to revisit

  • i18n is a small in-house lookup, not go-i18n: the original messages have no plurals or placeholders. Keys added for strings the original hardcoded or that are new: common.home, components.themeToggle.*, pages.home.*, pages.dashboard.{settingsForm,deleteAccount,live}.*, errors.{emailTaken,invalidInput,passwordTooLong,tooManyRequests}.
  • The dashboard gains a settings card (name only, email read-only) and a delete-account card. The original dashboard had neither.
  • The welcome copy now names Go paths (services, route wrapper, validate, config/features.go) instead of Next.js files. Hosting says "any container host" instead of Vercel.
  • CSS freshness is not checked in CI. That would download the 110 MB Tailwind CLI per run; make css regenerates it.
  • UI action paths (/logout, /locale, /dashboard/live, /dashboard/settings, /dashboard/delete-account, /static/) live in apps/web/ui/routes, because config is outside this PR. They can move into config.AppRoutes with the wiring PR.
  • The live-update hub is in-process. With several instances it needs Postgres LISTEN/NOTIFY or similar.
  • The verify-email success page POSTs the token from a Datastar data-init (link scanners doing GETs cannot spend it). It also handles links that auth's own GET /api/auth/verify-email already completed.
  • POST actions use stdlib http.CrossOriginProtection.
  • The theme toggle stores a theme cookie instead of localStorage, so the server can render the right class.

Merge Danger

Door: two-way. Reverting removes the pages and the UI: line. No migration, schema or stored data is added; the UI writes only through the existing auth and user service.

Blast Radius: web. Every non-API path (/ and the pages) is now served by the UI instead of 404. /api/* and /healthz are matched first and unchanged. Shared files: go.mod/go.sum (a-h/templ incl. its tool directive, datastar-go), cmd/server/main.go (+5 lines) and the Makefile (css target).

🤖 Generated with Claude Code

jayleaton and others added 10 commits October 22, 2026 14:09
Messages are the original apps/web-app/messages files with additive keys
only. Locale comes from the locale cookie, then Accept-Language (q-ordered).
Tests fail when a key used by templates is missing in any locale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Datastar is served from go:embed with its version and SHA-256 pinned and
tested. Tailwind uses the standalone CLI v4.3.3, downloaded into a
gitignored bin/ and checksum-verified. templ and datastar-go are added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ports every parity page (home, why, login, signup, forgot/reset password,
verify email and success, dashboard with settings and delete account) with
the original Tailwind classes. Forms post via Datastar and stream patched
fragments or redirects over SSE, with a no-JS fallback. The dashboard live
card is the server-push example: clock, session expiry, and account card
updates across tabs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
go run ./apps/web/ui/cmd/preview serves all pages with fakes on
0.0.0.0:3001. Handler tests cover status codes, SSE fragments, cookies and
the live stream over a real connection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The header controls stopped at max-w-3xl while pages run to max-w-6xl.
The nav now uses the same container as the content (max-w-6xl, px-4/6).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntract

Pages link through config.AppRoutes and read config.Features; ui/routes
keeps only the UI's own action endpoints. ui.Users is now the method set
of *services.UserService, so the service plugs in without an adapter, and
the settings form is validated with validate.UpdateUserSettings. Email
changes belong to the auth flows, so the settings form edits the name only
and shows the email read-only (copy updated in en and ja).

ui.Auth follows packages/auth: CallbackURL on sign-up/sign-in inputs, a
zero Session from SignUpEmail/VerifyEmail when there is nothing to sign in,
and ui.Err* values named after auth.Err* (new messages for too many
requests and passwords over 128 characters).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With packages/auth on main, ui.Auth uses its types (User, Session and the
inputs are aliases) and *auth.Auth satisfies it without an adapter. The
handlers match auth.Err* values directly instead of UI copies, the session
is read through Deps.UserFrom (default auth.UserFrom), and Handler()
returns the mux for server.Deps.UI. Compile-time assertions keep ui.Auth
and ui.Users in step with *auth.Auth and *services.UserService.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mounts ui.New(...).Handler() as server.Deps.UI with the real *auth.Auth and
*services.UserService, and adds make css to rebuild the committed Tailwind
output. templ generation is already part of make generate and the CI
generated-code check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jayleaton jayleaton changed the title feat(web-ui): templ + Datastar UI package with en/ja parity pages feat(web-ui): templ + Datastar UI with en/ja parity pages, served by the server Oct 22, 2026
@jayleaton
jayleaton marked this pull request as ready for review October 22, 2026 07:15
@jayleaton
jayleaton merged commit abc9f79 into main Oct 22, 2026
8 checks passed
@jayleaton
jayleaton deleted the feat/web-ui branch October 22, 2026 07:21
jayleaton added a commit that referenced this pull request Oct 31, 2026
The boundary check ran only inside make lint, which a change confined to
deploy/ (its own module) never selects, so a test file there merged
unchecked and failed the next full make lint on main (sparkrack #6). Plan
now adds scripts/check-test-boundaries.sh on its own whenever make lint is
not selected; its reuse input is every file.

deploy/ has no tests again (AGENTS.md "Test boundaries"): make deploy-check
and make deploy-vars cover the deploy config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jayleaton added a commit that referenced this pull request Oct 31, 2026
* ci: verify pull requests locally (make ci-local), Lockpick format

Every check now runs in `make ci-local` on the agent's machine, in a clean
worktree of the exact pushed head, selected from the diff against the PR's
base. It reuses steps whose inputs are unchanged since an earlier attested
head, takes a machine-wide slot, waits for memory, and can offload the
steps to a Davit box. It posts the `local-ci` commit status and fills the
PR body's "Local verification" section. A failed head is refused for good.

GitHub runs only two small jobs, started by that body edit: a gitleaks
secret scan and a check that `local-ci` and the section hold for the head.
Main and dev run no CI; Deploy gate passes only attested commits for
Railway's "Wait for CI". `make pr-merge` merges on local verification, and
on `local-ci` alone where Actions cannot run.

The rules live in scripts/cilocal/verify, a new test boundary (100%
covered). The migration order check moves into it, so it no longer needs
bash 4. The old single CI job, its check action and scripts are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): a Davit box drops files its image has and the head does not

Davit's sync adds and updates files but never deletes them, so a box made
from a project image built at an older commit kept the four files this
branch removes, and cilocal exec refused the tree (rightly: nothing ran).
The payload now carries the head's paths (gzipped), and exec removes the
rest before it checks the tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): a Davit box commits the head's tree before its steps run

Davit syncs the index but no commit, so git status in the box listed every
file as added and make check-generated failed on a clean head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: deploy main to staging and releases to production, Lockpick format

Railway's GitHub auto-deploy is off (platform.yaml repo: ""), so only the
workflows ship code:

- Deploy staging: every push to main that make ci-local attested, to the
  Railway staging environment (web, admin, backup, then /healthz).
- Staging catch-up: hourly, dispatches Deploy staging when staging is behind.
- Draft release: drafts vX.Y.Z at the commit staging runs.
- Deploy production: a published release, after a gate proving staging ran
  it, still runs it or later, is healthy, and the publisher may release.

Until RAILWAY_DEPLOY is true the deploys dry-run. main is the only deployed
branch; Deploy gate is gone. docs/RELEASES.md has the flow and setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: each deploy environment holds its own RAILWAY_TOKEN; deploy-check runs the deploy tests

- The staging and production GitHub environments each hold a secret named
  RAILWAY_TOKEN, a Railway project token scoped to that environment.
- Readiness needs /healthz to answer 200 with data.status ok.
- make deploy-check (and so make ci-local and make ci) runs go test in the
  deploy/ module, and deploy/platform is a test boundary for platform.yaml's
  rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs: name deploy config among the test boundaries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): the deploy test probe goes outside the deploy/platform boundary

deploy/platform is a test boundary now, so check-test-probes planted its
deploy probe where a test is allowed and the probe was not reported.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): every plan checks the test boundaries; deploy/ keeps no tests

The boundary check ran only inside make lint, which a change confined to
deploy/ (its own module) never selects, so a test file there merged
unchecked and failed the next full make lint on main (sparkrack #6). Plan
now adds scripts/check-test-boundaries.sh on its own whenever make lint is
not selected; its reuse input is every file.

deploy/ has no tests again (AGENTS.md "Test boundaries"): make deploy-check
and make deploy-vars cover the deploy config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): make pr-merge reads CI jobs GitHub never started as not run

On an account whose Actions are blocked by billing, GitHub still creates
the CI run and fails each job within seconds, with no runner and the
annotation "The job was not started because recent account payments have
failed...". make pr-merge counted those as failed checks and refused, so
local-ci alone could never merge there. It now reads each failed CI job's
annotations and treats a never-started job as no run (--require-actions
still refuses). The ci-local hint is shown only for verification problems.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ci): make ci-local attests what you ran instead of running it

make ci-local RAN="<what you ran>" now runs nothing. It refuses a dirty,
unpushed or already-failed head, posts local-ci on the exact SHA
("<result> · body <digest> · by <author> on <host>") and fills the PR
body's Local verification section with the head and the statement.
FAILED= records a failure, final for that head; COMMIT= attests a main
commit. It takes seconds: no worktree, plan, slot, Davit box or memory
wait.

- verify: StatementOf, RenderSection, ClaimsHead, BodyProblems and
  LocalVerificationProblems (the section names the head and says what
  ran, its digest matches local-ci, the head never failed), shared by
  cilocal check, merge and attest.
- ci.yml: a "Local verification claim" job; the checking job is named
  "Local verification" only for a claimed head, "Awaiting make ci-local"
  otherwise, so an unattested PR shows Expected, not red.
- make pr-merge: Decide() per PR (draft, stacked, conflict, attestation,
  failed, hollow, pending, mergeability), latest attempt per job; CI jobs
  GitHub never started still count as not run, and with no CI job at all
  it merges on local-ci alone (--require-actions refuses). Prints the
  squash SHA and --check-deploy <sha>, a one-read staging verdict
  (Deploy staging runs plus <STAGING_WEB_URL>/healthz; a dry run says
  nothing deployed); --check-staging and --then-check-staging.
- Post-merge report: comments on the merged PR when Deploy staging
  fails on main; a verify test fails when a push-to-main workflow is
  missing from its list.
- Removed: the step plan, runner and exec, reuse ledger, Davit routing,
  machine slot, memory wait, failure triage, report save/load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(ci): run what the change needs, attest, stack, merge at once

docs/CI.md: "What runs" (the claim job and the Awaiting name), a new
"What to run" table for this stack, "Local verification" (RAN, FAILED,
COMMIT), "Merging on local verification" (Decide, --check-deploy,
--check-staging, the Post-merge report) and "When Actions cannot run"
with the never-started-jobs rule kept. The Davit, slot, memory, reuse
and plan sections are gone.

AGENTS.md "Verifying", "Pull requests" and "Environments", CONTEXT.md
(claimed, attested, failed head; no ledger), README, development.md and
the deploy docs say the same. The github-prs skill, adapted to make
ci-local and make pr-merge, is at .claude/skills and .agents/skills,
linked from AGENTS.md and CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): the PR checks run only main's code; attest only what was checked

Randy's review of #134 (bf0b5d3), applied to the attest-only redesign:

- P1, PR-controlled gate: ci.yml ran on pull_request, so the pull request
  supplied the workflow and the scripts/cilocal it ran; a fork adding
  `func init() { os.Exit(0) }` passed Secret scan and Local verification,
  and the scan used the fork's .gitleaks.toml. ci.yml now runs on
  pull_request_target: GitHub takes the workflow and the checkout from
  main, and the jobs read the head as data only. `cilocal secret-scan
  --head <sha>` copies the head's files out of git's objects
  (verify.ScanTargets; a path leaving the scan directory fails closed) and
  scans them with main's scanner and config. A fork's head fails Local
  verification, make pr-merge and the staging gate (Verification.Fork,
  verify.Claimed), and openPull skips a fork PR that shares the branch's
  name. verify.TrustProblems holds ci.yml to all of it.
- P1, publishing a refused run: publish now reads the checkout again just
  before it posts and refuses unless the report is for exactly that clean,
  pushed head and tree (verify.PublishRefusal). The save/--publish path
  Randy reproduced was removed in de66901; this is the one choke point now.
- P2, first attestation: a main attestation finds the last attested
  first-parent ancestor (at most 50; a root or shallow-first commit has
  none). With none, as in a new project, the statement must name make ci
  (verify.FirstAttestationProblem). Documented in docs/CI.md, README,
  AGENTS.md and the github-prs skill.

Regression tests in scripts/cilocal/verify (still 100% statement coverage,
which also covers two workflows.go helpers 9068dd7 left at 99.4%):
TestCIRunsOnlyTrustedCode and TestTrustProblems (they fail on the ci.yml of
bf0b5d3 and 9068dd7), TestScanTargets, TestClaimed, the fork cases of
TestLocalVerificationProblems, TestPublishRefusal, TestLastAttested,
TestRanFullCheck and TestFirstAttestationProblem.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): the secret scan never takes its policy from the change it scans

Randy's re-review of #134 (2f1cca7): the Secret scan job used main's
.gitleaks.toml, but gitleaks also loads .gitleaksignore from the root of
the directory it scans, where the head's files were copied. A head adding
a .gitleaksignore with a finding's fingerprint passed the scan.
--gitleaks-ignore-path alone does not stop it: gitleaks 8.30.1 still loads
the scanned root's file. Inline gitleaks:allow comments were a second way
for head content to act as policy.

- Policy (verify.ScannerPolicy: .gitleaks.toml, .gitleaksignore) comes only
  from the trusted commit (the checkout's HEAD, main in the job; --base
  locally), written to a directory beside the scanned one, with gitleaks'
  defaults and an empty ignore list where it has none.
- The change's own policy-named files are copied in as content under
  another name (verify.ScanCopyPath); gitleaks runs with
  --ignore-gitleaks-allow and without GITLEAKS_* env (verify.GitleaksArgs).
- A change that adds, edits or removes a policy file fails the scan for
  review even when it finds nothing (verify.PolicyChanges, PolicyProblem).

TestHeadPolicyIsNeverScannerPolicy is Randy's repro: both heads fail.
scripts/cilocal/verify stays at 100% statement coverage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ci): the secret scan allows fixture values, not whole paths

---------

Co-authored-by: JJ Eaton <58495321+oldbettie@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Danny <danny@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant