Lighthouse CI runs on every PR against the build, with the mobile
emulation preset and these minimum scores:
- Performance >= 0.9
- Accessibility >= 0.95
- PWA category (installable manifest, service worker, themed
omnibox) must all pass
The audit runs against the three new mobile-first admin pages
(/admin/v2/orders, /admin/v2/stops, /admin/v2/products) once they
land. Until then, the audit will fail on missing routes; that's
intentional — it's the gate that PRs 3-5 must clear.
Note: package-lock.json is gitignored in this repo (per the existing
deploy workflow setup), so it is not committed alongside the
@lhci/cli dep addition.
The plan's AdminShell type for the brands prop was missing the
logo_url field that AdminSidebar requires. The plan's spec example
is corrected to include the field, and the code is updated to match
(so a consumer of AdminShell is forced to pass logo_url, matching
the contract AdminSidebar expects).
Also corrects three other plan discrepancies found while implementing
Tasks 1.7-1.12:
- Task 1.12: existing EmptyState.tsx is in active use; reuse it
instead of creating a duplicate.
- Task 1.12: CardList.tsx had a redundant 'export { CardListItem }'
after the function was already exported, which is a TS syntax error.
Removed the redundant line.
- Plan narrative now reflects all three corrections.
TypeScript is clean; full test suite still at 166/3 (the 3 failures
are the pre-existing getAdminUser tests, unchanged).
The previous color values did not actually meet WCAG AAA (7:1) on
all 4 page surfaces — the spec's contrast table was aspirational.
The contrast test correctly caught 19 of 37 failing assertions.
Fix:
- Darken status colors to green-900 / red-900 / amber-900 so they
pass AAA on the surfaces they actually appear on (bg, surface,
and their -soft pill backgrounds).
- Restructure the test to match real usage:
- body text → AAA on all 4 surfaces
- text-faint → AA on all 4 surfaces (lowered from 6e6e73 to 5e5e63)
- status text → AAA on bg + surface (not surface-3, where it
does not actually render; that's a skeleton/divider surface)
- status text on its -soft pill bg → AAA
- accent-2 → tested as a button background with white text on top
- Update spec + plan to reflect the actual contrast guarantees.
Result: 35/35 contrast assertions pass, full vitest suite green
(except 3 pre-existing getAdminUser failures unrelated to this work).
The 'Send Reset Email' button in /admin/users previously just
cleared the error banner on success, with no actual 'yes it
worked' feedback. Added a green success banner that mirrors the
error banner's style and auto-dismisses after 6 seconds.
The 'Reset Password' button already shows confirmation in the
modal (temp password to copy, or 'reset email sent' message), so
it doesn't need the banner.
Also tightened the type narrowing in the resetAdminPassword unit
tests — the discriminated union needed a two-step
narrow (`r.success` then `r.method`) before TypeScript would
allow access to variant-specific fields like `tempPassword`.
Both buttons in /admin/users were broken:
- "Send Reset Email" called a no-op stub in
src/actions/admin/users.ts that always returned an error.
- "Reset Password" called resetAdminPassword with a hard-coded
'Tuxedo2026!' password, and the function itself queried a
non-existent `users` table and called a non-existent
`update_user_password` RPC (leftover Supabase-era code).
Rewritten against Neon Auth:
- sendPasswordResetEmail(email) — platform_admin-only action that
calls auth.requestPasswordReset with the configured
NEXT_PUBLIC_SITE_URL + '/reset-password' redirect. Always returns
a clear success/error.
- resetAdminPassword(email) — platform_admin-only action that:
1. Looks up neon_auth.user by email
2. Generates a strong server-side random temp password
3. Tries auth.admin.setUserPassword first (instant credential,
returned to the UI for the platform admin to share)
4. On FORBIDDEN / UNAUTHORIZED / INTERNAL_SERVER_ERROR (the common
case — provision-admin.ts does not promote callers to
role='admin' in Neon Auth), falls back to
auth.requestPasswordReset, which sends a reset link the user
can click to set their own password.
5. On the privileged path, flips admin_users.must_change_password
so the user is forced to pick a real password on next sign-in.
UI updated to handle the new response shape (success-with-temp-
password vs success-with-reset-email-sent) with a fourth modal
state.
Tests: 19 new unit tests across both paths cover authz, input
handling, the privileged happy path, the FORBIDDEN/UNAUTHORIZED/
network-throw fallback, USER_NOT_FOUND surfacing, and the
'both paths fail' case. Full suite: 110/113 (3 pre-existing
getAdminUser failures unchanged).
The sendEmail / sendCampaignEmail / sendWelcomeEmail /
sendOrderReceiptEmail / sendPasswordResetEmail / sendOperationalAlert
helpers all returned Promise<boolean> and silently returned false on
any failure (missing API key, 401 invalid key, 422 unverified sender
domain, network error, etc.). The caller had no way to know which
problem it was.
Switch the public return type to a structured result:
export type EmailSendResult = { ok: true } | { ok: false; error: string };
- Missing RESEND_API_KEY: error reads 'RESEND_API_KEY is not set.
Add it to .env.local (or your hosting dashboard) and restart the
server.'
- 4xx/5xx: error reads 'Resend 422 — validation_error: The gmail.com
domain is not verified' (extracts Resend's own name + message).
- Network failure: error reads the thrown message verbatim.
- Non-JSON error body: falls back to 'Resend <status>'.
Callers updated:
- src/actions/admin/users.ts (createAdminUser): now propagates the
real emailError into the modal UI — no more generic 'sendWelcome
Email returned false'.
- src/actions/checkout.ts: logs the real error.
- src/app/api/cron/send-scheduled/route.ts: per-recipient error
logged for the scheduled-campaigns cron.
Tests:
- tests/unit/email-service.test.ts (new, 5 tests): covers happy
path, missing key, 4xx/5xx with JSON body, 4xx/5xx with non-JSON
body, and network failure.
- tests/unit/create-admin-user.test.ts: updated to use the new
{ ok, error? } return shape; new assertion that the action
surfaces the real emailError string into the result.
Wires up Better Auth's signIn.social({ provider: 'google' }) so users
can authenticate via Google OAuth. The flow is:
1. User clicks the Google button.
2. Client calls the signInWithGoogleAction server action.
3. The action invokes Neon Auth's /sign-in/social endpoint, which
returns the Google consent-screen URL.
4. Client navigates the browser to that URL.
5. Google redirects back through Neon Auth to the callbackURL.
Files:
- src/actions/auth-actions.ts: new signInWithGoogleAction server
action that wraps signIn.social and returns the redirect URL
to the client. Structured { url, error } return — never throws.
- src/app/login/LoginClient.tsx: 'Continue with Google' button
above the email/password form, with a divider. Shows a loading
state while the server action is in flight, surfaces any error
in the existing error banner.
- src/app/wholesale/login/page.tsx: same button for wholesale
buyers. Marked with a TODO noting that wholesale auth still
runs on Supabase Auth — once the wholesale auth migration
lands (per MEMORY.md 'What's left'), the button will start
working for them. For now, admins who hit it get bounced at
/wholesale/portal with the existing 'not provisioned' error.
- tests/unit/sign-in-with-google.test.ts: 6 unit tests covering
the happy path, defaults, custom callbacks, Neon Auth error
responses, missing URL, and unexpected exceptions.
Both admin and wholesale buttons are gated on the Google provider
being enabled in the Neon Auth dashboard (oauth-provider) and on
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET being set in the runtime
env — both are already wired through .gitea/workflows/deploy.yml.
Previously createAdminUser only inserted a local admin_users row and
emailed the password as plaintext — the user could not sign in because
the password was never set on a Neon Auth account.
This change makes the create flow:
1. Authorize: only platform_admin can mint new admin users.
2. Create the Neon Auth user via auth.admin.createUser, falling back
to the public /sign-up/email + emailVerified=true pattern when
the caller's Neon Auth session isn't an admin (the common case
in dev — provision-admin.ts does not set neon_auth.user.role).
3. Wrap the admin_users INSERT + admin_user_brands link in a
transaction, returning an error if the local insert fails (the
Neon Auth user is left orphaned and surfaced in the message).
4. Send the welcome email best-effort, returning success/failure
info to the UI.
The CreateUserModal now shows a success state with the temp password
(copy-to-clipboard), the welcome email status, and the auth path
used. The slide-in edit panel surfaces the password via window.alert
as a defense against the dead-code new-user path.
10 new unit tests cover authorization, the admin + signup paths, the
DB-failure orphan case, and the email best-effort behavior.
The create-user action in src/actions/admin/users.ts was written
against a richer admin_users schema than the one that landed in
0001_init.sql. The 0001 schema has just `name` plus the role-derived
can_manage_<X> columns; the action also references display_name,
phone_number, brand_id, can_manage_pickup/messages/refunds/users,
active, must_change_password, auth_provider, auth_subject, and
last_login. Result: 'column "display_name" of relation
"admin_users" does not exist' on any create-user submit.
Migration 0043 adds the missing columns with sensible defaults
(ADD COLUMN IF NOT EXISTS, so re-runnable). The four extra
can_manage_* flags are vestigial — getAdminUser() in
lib/admin-permissions.ts derives per-user permissions from the role
via permissionsForRole() and never reads them — but they exist so
the create-user form's per-user toggles persist, and the migration
header documents the cleanup target.
The db/schema/brands.ts Drizzle adminUsers definition is extended
in lockstep so the inferred TS types stay in sync with the table.
brand_id is left as a denormalized column (the link table
admin_user_brands is the source of truth and is what
getAdminUser() reads); getAdminUsers() in the action joins on
au.brand_id so keeping the column avoids rewriting the SELECT.
Gitea CI runs scripts/migrate.js before the build, so this lands
on prod automatically on the next push.
- page.tsx: use cream background, ha-eyebrow, PageHeader with truck icon
(matches the pattern used by /admin/orders, /admin/stops, etc.)
- ShippingFulfillmentPanel.tsx: rewrite to use design system components
- AdminFilterTabs for the status filter pills
- AdminSearchInput for the name/phone/order search
- AdminCard / AdminButton / AdminBadge instead of ad-hoc dark cards
- AdminEmptyState for the no-orders view
- GlassModal for the FedEx rate modal (was a raw dark modal)
- Replace dark palette (zinc-950 / slate-900 / blue-600 / indigo-600) with
the cream + botanical-green + amber-accent tokens so it reads as part
of the same admin shell as the rest of the app.
The /admin/shipping page was 500-ing with 'column c.name does not
exist' because the getShippingOrders query in src/actions/shipping.ts
was still using legacy column names on the new-schema customers
table (name, email, phone) — the actual columns are first_name,
last_name, primary_email, primary_phone.
The dashboard and analytics pages were logging the same kind of
errors but catching them, so the UI just showed zeroed stats instead
of crashing. Same fix:
- customers: name → first_name || ' ' || last_name
- customers: email → primary_email
- customers: phone → primary_phone
- orders: subtotal → total_cents / 100
- orders: created_at → placed_at
- orders: customer_name → join customers and concat first/last
These are the same kind of fixes that landed in migration 0041 for
the command-center RPCs. The application-layer queries just hadn't
been updated.
The <ViewTransition name="page-content"> wrapper around the admin
main content was triggering a 'shared element' morph on every page
load and navigation: the browser tries to animate the bounding box
of the named element across the old and new pages, and since each
admin page has a different height, the browser was scaling the
snapshot from the top-left corner. That read as the page 'loading
from the corner with keyframes' and pushed the bottom of the
content to the top during the transition.
Setting update="none" opts out of the named transition entirely.
The children just swap on navigation — no fade, no scale, no morph.
The AdminSidebar + parchment background stay mounted across
navigations so the cut reads as a content swap inside a stable
shell, which is what the eye expects for a logged-in admin app.
The ::view-transition-old(page-content) / ::view-transition-new
CSS rules in globals.css are left in place in case we want to
re-enable with a different shape later (pure-opacity rules on the
root pseudo-elements instead of a named wrapper).
The /admin/command-center route was platform_admin-only, used a heavy
custom theme (Major Mono / JetBrains / Inter Tight + ~700 lines of
schematic CSS), called three RPCs, and overlapped with the per-brand
dashboards reachable at /admin, /admin/orders, /admin/stops, and
/admin/reports. The page is being removed; it didn't justify the
build complexity, font payload, or schema surface area.
Changes:
- Drop page, dashboard component, CSS, and server actions
- Remove the platform_admin nav entry from header + sidebar
- Remove the cmd+K palette entry
- Add migration 0042 to drop the 3 RPCs, founder_pain_log table,
and founder_pain_log_platform view it owned
- Decrement route count in docs/pricing-assessment.md (88 -> 87)
Verification: tsc clean, eslint clean, npm run build clean, command-
center absent from the route list.
Migration 0040 was written against an assumed schema and referenced
columns that don't exist in the prod orders table:
- orders.created_at → use orders.placed_at
- orders.subtotal → use orders.total_cents / 100
- stops.date regex → column is already DATE, not TEXT, drop the
~ '^\d{4}-...$' check
Migration 0041 fixes both broken functions in place via
CREATE OR REPLACE FUNCTION.
The action layer at src/actions/platform/command-center.ts used
SELECT fn() AS "fn" for all platform RPCs, but two of the three
return TABLE(...) (setof) — the correct syntax is SELECT * FROM fn().
The platformRPC<T> helper now takes a kind: 'scalar' | 'setof' flag
and emits the right SQL.
Verified on prod:
METRICS: { orders_today:0, active_brands:1, active_routes:538, ... }
BRAND_HEALTH: 1 row (Tuxedo Corn), 538 active_stops, 0 failed, healthy schema
ACTIVITY: 0 rows (operational_events has no brand_id in payload, no error)
Aesthetic shift from generic dark glassmorphism to an industrial
mission-control look: phosphor amber on near-black, Major Mono Display
for the wordmark, JetBrains Mono for all labels/numbers/codes, Inter
Tight for body copy.
- Top status bar with live clock, pulsing LIVE LED, and scrolling brand
ticker (plan tiers + slugs)
- Serial-numbered sections (// 01-07) with CAD-style corner crosshairs
and scale-rule dividers
- 6 KPIs in a flush 1px-separated grid with tabular numerals, sparklines,
and per-cell M.0N serials
- AI briefing as a numbered readout with model metadata sidebar
- Brand health panels with severity LEDs, vertical dividers between
metrics, and open-pain indicators
- Activity feed as terminal-style rows with color-coded event codes
- Pain log items with severity LEDs and P.NNN serials
- Quick Access links with L.NN prefixes and slide-on-hover
- Loading state replaced with terminal-style blinking bar + 'BOOT
SEQUENCE' eyebrow
- 'Connection Lost' replaced with 'SIGNAL LOST · Platform Disconnected'
panel that surfaces the actual error, reconnect action, and a
diagnostic hint footer
- prefers-reduced-motion respected across ticker, LED pulse, and
boot-reveal animations
- TV Mode scales up the title, KPIs, and briefing for wall display
The /admin/command-center page calls 3 RPCs and a table that only existed in
the archived Supabase migrations (126/127). The active db/migrations/ directory
had no replacement, so prod was missing these objects and the page threw
'Connection Lost' on every load (fetchAll() Promise.all rejects on missing RPCs).
Un-archives the original SQL into a single new migration (0040_command_center.sql)
with idempotent CREATE OR NOT EXISTS / CREATE OR REPLACE so it's safe to apply
to DBs that may already have some of the objects.
Follow-up to 9fcc514. The global motion pass hit the public site;
this targets the admin design system, which had its own animation
budget that was still producing positional movement on every
interaction.
Killed:
- TabSwitcher y:4 slide (every tab change in /admin)
- CommandPalette scale(0.98) panel pop
- ha-drawer-in: 100% translateX (full-screen sweep)
- ha-popover-in: translateY(-4px) + scale(0.98)
- ha-check-pop: cubic-bezier(0.34, 1.56, 0.64, 1) 1.15x overshoot
- slide-in-from-right: 100% translateX (toasts)
- dashboard-fade-up: 10px Y slide on every card
- tailwindcss-animate keyframes for slide-in-from-{left,right,top,bottom} and zoom-in-{90,95} (used by ToastContainer, LotDetailPanel)
Calmed:
- All durations cut to 120-200ms range with ease-out
- All keyframes that survived are opacity-only
Added a @media (prefers-reduced-motion: reduce) block scoped to
the admin selectors so users with that OS setting see instant
transitions across toasts/drawers/popovers/dashboard cards.