console-auth-access-polishrun.md02_define/output/spec.mdAn invited partner's first two screens are the invitation accept flow and the sign-in page, and
they are the least designed surfaces in the console: an unstyled centred <main> with a bare
Clerk card on it, a terse "No access" refusal, and a staff access page that predates the batch's
page-header, empty-state and loading standards. The console-ui-polish batch exists because
partners judge Sustentus by this app — and the judging starts one screen before the console shell
the batch has already polished. Credibility on these surfaces advances Q2-2026 Objective 1 —
Establish Product-Market Fit with Vendor Partners — under the Scale the Bridge initiative. This is
the batch's last stub; the auth pages are seen once per user, but first by every new partner.
A presentation pass over the (auth) segment, the no-access page and the staff access page.
Visual/UX only (Jamie's ruling, 2026-08-27): the auth mechanics do not move, and every access-
control call site is read-only to this run.
Merge-order dependency. The batch's shared standard — components/page-header.tsx,
components/empty-state.tsx, components/console-skeletons.tsx and the per-route loading.tsx
convention — is delivered by console-dashboard-activity-polish
(#907), which is open at its merge gate, not yet
on main. This run adopts those components rather than defining a second standard, so Build
starts after #907 merges and rebases this branch onto main first. Nothing here re-implements or
edits PageHeader or EmptyState; console-skeletons.tsx is extended (below), not rewritten.
(auth) layout gains the console's brand frame around whatever
Clerk renders: the sidebar's S mark and the "Sustentus console" wordmark, a one-line statement
of what the app is, and brand background/spacing — correct in light and dark. The frame is one
component, shared by (auth) and no-access so a partner's first two possible screens match.appearance already exists — the shell run
put ThemeAwareClerkProvider in components/providers.tsx, mapping Clerk to the shadcn/dark
base themes and the console's --primary / --card / --overlay variables. This run does not
rebuild it; it closes the element-level gaps that still read as stock Clerk inside the new frame
(card border/shadow reconciled with the frame, header and button typography on brand), extending
that same provider-level appearance in both themes.SignIn component keeps its local
appearance.elements.footerAction.display = "none", and its comment explaining why invitation-
only is enforced in this app rather than at the Clerk instance. No sign-up affordance appears on
any surface this run touches./accept restyled, never re-gated. The ticket logic is untouched: __clerk_ticket absent
and no catch-all continuation segment still returns the refusal instead of the SignUp
component; everything else still renders SignUp with forceRedirectUrl="/" and
signInUrl="/sign-in".
Reading of the stub's "byte-for-byte" criterion, stated for approval: it binds the condition
and the outcome (no sign-up form is reachable without an invitation), not the pixels — a
presentation pass that could not restyle the refusal card would exclude the surface it names.
The refusal card is restyled into the new frame; its message keeps its meaning.support@ and no mailto; the
person who invited them is their route back in, and the copy points there. It keeps rendering no
authenticated identifiers (the existing comment and its reason stay) and keeps the SignOutButton.
No new links.PageHeader replaces the hand-rolled heading block; the
four "nothing here" paragraphs (no pending invitations, nobody with access, no account for that
address) become EmptyState; a segment-level loading.tsx covers the page's requireStaff() +
readConsoleRoster() round-trip to Clerk. /access has no child routes, so the loading file is
safe there for the same reason it was on /activity.FeedRowsSkeleton, ListCardSkeleton); the access page needs a table shape. Build adds a
table-row skeleton to components/console-skeletons.tsx — the existing home for the console's
loading shapes — rather than a second skeleton file.(console) frame clamps horizontal overflow
(overflow-x-hidden), so a table that cannot fit scrolls in its own container, per the layout's
own note — the page never pushes the frame sideways.Context budget: read the console auth/access/layout/provider sources and PR #907's new components
in lieu of technical/architecture/repository-structure, to pin exact behaviour and the standard
this run adopts. No scope.md exists for this batch — rulings trace to the intake breakdown
(Jamie, 2026-08-27) and to the Define questions answered 2026-08-28.
/accept render inside the shared branded frame — mark, wordmark, one-line
statement, brand background — and both are correct in light and dark themes.SignIn component's
footer action stays hidden, and neither the frame nor the no-access copy adds a route to one./accept without __clerk_ticket and without a continuation segment renders the refusal and
no SignUp component; with a ticket, or on a continuation segment, it renders SignUp with
the same forceRedirectUrl and signInUrl as today. The condition and outcome are
unchanged; only the refusal's presentation moves.PageHeader, and each of its four zero states renders EmptyState
— the batch's components, imported, not re-implemented./access has a loading.tsx whose skeleton mirrors the page it stands in for (header, then
the invite form and two tables), built from shapes in components/console-skeletons.tsx.NOTICES; requireStaff() still gates the page.console-access marker, grant/revoke semantics, and Clerk instance
configuration — including any instance-level sign-up restriction, which apps/console/AGENTS.md
forbids because the platform's own self-registration shares the instance.proxy.ts, requireViewer/requireStaff, and the live-read-vs-claim semantics behind them.apps/web's self-registration and any other surface on the shared Clerk instance.console-dashboard-activity-polish (#907) owns
PageHeader, EmptyState and console-skeletons.tsx; this run adopts them and adds one table
shape to the last.(console) shell, sidebar and header — console-shell-sidebar, already shipped.console-tenants-polish, the batch's remaining stub.03_build/output/notes.mdfeat: console-auth-access-polish — branded auth frame, no-access copy, access page to the batch standardapps/console.apps/console/components/auth-frame.tsx (new): the branded frame shared by the (auth)
segment and /no-access — mark, wordmark, one line on what the console is, brand background.
One component rather than three page layouts, because these are the first screens an invited
partner sees and they should not each look like a different product.apps/console/app/(auth)/layout.tsx: renders the frame instead of a bare centred <main>.apps/console/app/(auth)/accept/[[...accept]]/page.tsx: the refusal card restyled onto
EmptyState inside the frame. The ticket logic is byte-for-byte unchanged — same
!ticket && !isContinuationStep condition, same SignUp props, same comment explaining why
invitation-only lives in this app rather than at the Clerk instance.apps/console/app/(auth)/sign-in/[[...sign-in]]/page.tsx: untouched. Its local
appearance.elements.footerAction.display = "none" is what keeps the sign-up affordance off
the page, and Clerk merges it over the provider-level appearance, so it needed no change.apps/console/components/providers.tsx: extended the existing ThemeAwareClerkProvider
appearance at element level only — the Clerk card takes our border and radius and drops its
shadow, so it reads as one of our cards inside the frame; Clerk's own logo is hidden because
the frame already carries the mark. The theme-aware baseTheme mapping the shell run
established is unchanged.apps/console/app/no-access/page.tsx: same frame, and copy rewritten in brand voice —
invitation-only stated plainly, the person who invited them named as the route back in. No
contact address and no links, per Jamie's ruling (2026-08-28). Still renders no authenticated
identifiers, and keeps Sign out.apps/console/components/console-skeletons.tsx: added TableRowsSkeleton and
TableCardSkeleton. The batch's shapes were all feed-shaped; the access page is the first with
tables, so the table shape lands in the shared file rather than a second one.apps/console/app/(console)/access/loading.tsx (new): header, invite form and both tables,
built from those shapes. Safe as a segment-level loading file — /access has no child routes.apps/console/app/(console)/access/page.tsx: PageHeader replaces the hand-rolled heading;
the zero states become EmptyState; the action column gets whitespace-nowrap so its buttons
stay intact when the table scrolls. No change to any action, notice or the requireStaff() gate./accept inside the branded frame, correct in both themes — the frame is built
from semantic tokens (bg-muted/30, bg-primary, text-primary-foreground), so it follows
the theme rather than declaring colours.SignIn's footer action stays hidden (that
file is untouched), and neither the frame nor the no-access copy adds a route to one./accept refusal condition and outcome unchanged; only its presentation moved.EmptyState" — met for every zero state that
exists, but there are three, not four: no pending invitations, nobody with access, and
no account for a looked-up address. All three now render EmptyState. The "four" is a
miscount in the spec, not a gap in the page — nothing was dropped. Flagged rather than
papered over; see Notes for Verify./access has a loading.tsx mirroring the page, built from console-skeletons.tsx.@sustentus/ui Table primitive
already wraps itself in overflow-x-auto, so a table that cannot fit scrolls in its own
container and never pushes the console frame sideways.NOTICES banner behave as
before; requireStaff() still gates the page.EmptyState, and treat the
count as a spec-text correction rather than an unmet criterion. Worth fixing in spec.md if
Verify wants the document to match the page.card border/radius/
shadow, logoBox hidden) are the only thing here that can look wrong without failing a build.
Check sign-in and /accept?__clerk_ticket=… in both light and dark on the preview — especially
that hiding logoBox didn't leave a gap at the top of the Clerk card.(auth) layout and /no-access each render exactly one <main> — /no-access sits
outside the (auth) group, so the frame is not nested.04_verify/output/verify.md297beb7 — settled via ci-status.sh after the last push. The last push
carrying code was 0bd580a (the three review fixes), which also settled GREEN; 297beb7 and
the commit recording this line are .icm-only, so no app rebuilt for them and Vercel reports
their previews skipped — correctly, not as a failure.tenant-management-git-claude-console-auth-acce-788033-sustentus.vercel.app) is behind
Vercel deployment protection. Unauthenticated requests 302 to vercel.com/sso-api, which
answers 200 with Vercel's own login page. A naive curl -L therefore looks like a healthy
200 while serving nothing from this app — worth knowing, because it is exactly how a preview
smoke gets faked by accident. The build itself is real: Vercel – tenant-management built for
this commit; it just cannot be reached from here.(auth) segment and the Clerk provider), so each concrete check was
performed rather than waived on a glance: no migrations in the diff; no db/models or
db/services changes; turbo.json globalEnv unchanged; no new process.env reads anywhere
in the diff; no route-policy file touched. Nothing for the readiness pass to bite on.complexity: standard) — 3 findings, all real, all fixed on
branch in 0bd580a. Detail below.proxy.ts, console-access.ts,
requireViewer/requireStaff and access/actions.ts are absent from the diff entirely; the
/accept ticket condition is byte-identical; the sign-in footerAction: none override is
merged over the new scoped appearance rather than replaced, so invitation-only enforcement is
intact. No user input reaches any new code path, no dangerouslySetInnerHTML, no new routes,
no Clerk instance configuration touched.Nothing below is ticked. The agent could not reach the preview (see above), so every line
is the operator's — including the ones that would normally be agent-run, because /sign-in and
/accept are public in proxy.ts but not public through Vercel's protection.
/accept render inside the branded frame — mark, wordmark, one-line statement,
brand background — correct in both themes (operator)/accept with no __clerk_ticket and no continuation segment → refusal card, no sign-up
form; with a ticket → the sign-up flow, same redirect targets (operator)PageHeader; its three zero states render EmptyState (operator)/access loading skeleton mirrors the page (operator — throttle the network to see it)NOTICES banner behave as before;
requireStaff() still gates the page (operator)UserButton popover in the console shell
still has its shadow and no stray border (operator) — this is what finding 1 brokeproviders.tsx — elements.card override was global, not auth-only. It sat on
ClerkProvider, so it reached every Clerk surface carrying the cl-card descriptor — including
the UserButton popover in the console shell, which lost its elevation and gained a border over
the page beneath. A regression introduced by this run's Build commit, in a surface the run does
not otherwise touch. — fixed in 0bd580a: moved to components/auth-appearance.ts, applied
only by the two pre-console screens.providers.tsx — borderRadius: var(--radius-xl) did not do what its comment claimed.
--radius-xl is declared inside @theme inline in packages/ui/src/globals.css (line 281),
which Tailwind consumes to generate utilities and never emits as a runtime custom property. The
var() fell through to Tailwind's default 0.75rem, giving the Clerk card 12px corners beside
our 16px ones — the exact "reads as one of ours" goal the change existed to serve, silently
missed. — fixed: spelled out as calc(var(--radius) + 4px).EmptyState renders its title as a paragraph, so /accept and /no-access lost their
headings. Both previously had an <h1>; after the Build commit all three pre-console screens
announced only the frame's generic "Sustentus console", with the actual message as body text.
— fixed: added an optional titleAs prop to EmptyState (defaults to "p", so
console-dashboard-activity-polish's callers are unchanged) and passed "h2" on both screens.
This edits a component the spec lists under Out of scope; the extension is additive and
behaviour-preserving for every existing caller, which is why it was done here rather than
bounced back to Define.EmptyState, so
the page is complete — the document is wrong, not the code. Build flagged it and left the
criterion unticked on the PR rather than counting to four. Correct it with a one-line
/pipeline define console-auth-access-polish revision if the spec should match the page.Context budget: within the Inputs table. The preview-protection finding came from probing the preview host directly, which the DoD smoke step requires.
05_ship/output/changelog.mdThe screens you see before the console are the ones a newly invited partner sees first, and until now they were the plainest part of the product. They have been brought up to the same standard as the rest. Nothing new to do, nothing new on show, and no change to who can get in or how.
Signing in and accepting an invitation look like Sustentus. Both screens now sit inside the same frame — the mark, the name, and a line saying what the console is for — so the first two screens a partner can land on no longer look like two different products. Correct in light and dark, as everywhere else.
Being turned away now explains itself. Someone who signs in without console access used to get a bare refusal. It now says plainly that accounts here come from an invitation and never from signing up, and suggests asking whoever invited them to send it again.
Managing who has access matches the other pages. For the Sustentus team, the access page now opens with the same header as everywhere else, arrives in shape while it loads, and says what each empty list means — no invitations outstanding, nobody granted access yet, no match for a search — rather than leaving a blank. Both tables stay readable on a narrow window.
Inviting, granting and revoking work exactly as they did.
The tenant pages are next.
05_ship/output/investor-update.mdWho it's for: Sustentus staff and delivery partners What shipped: Sign-in, invitation and no-access screens now carry the brand; the access page matches the rest. Why it matters: Partners judge us by this console — credibility here advances Q2 Objective 1, Establish Product-Market Fit with Vendor Partners, under Scale the Bridge.
Dig deeper: https://github.com/sustentus/sustentus/pull/910 · https://help.sustentus.com/changelog/2026-08-28-console-auth-access-polish
05_ship/output/release.mdtechnical/applications/console documents the
access model, the route table and the invitation mechanics, and this run moved none of them:
the ticket condition on /accept is byte-for-byte unchanged, proxy.ts is untouched, and no
route, audience, env var or capability changed. Presentation only, so every sentence on that
page stays true as writtenbusiness/** page describes the console's
pre-sign-in screens, and no persona gained or lost a capability.github/workflows/ship-note.yaml reads it from the
squash and emails the #product-update channel; nothing sends before the authorised mergeapps/docs/archive/pipeline-runs/; the console-ui-polish
batch stays live in intake — console-tenants-polish (sequence 3 of 4) has not been spun out,
so this run is the batch's last stub only in the spec's wording, not in fact/accept render inside the shared branded frame — AuthFrame is imported by
the (auth) layout and /no-access; mark, wordmark and one-line statement are in the one
component, on semantic tokens that resolve in both themes — code-verified (agent). The
visual pass is the operator's line in verify.md and is not ticked there; the merge is
authorised past it by Jamie's gatefooterAction: { display: "none" }
survives the merge with the scoped appearance on the SignIn call site (the one override
that is not presentation, commented as such); neither the frame nor the no-access copy
adds a route to one — diff-verified (agent)/accept's ticket condition and outcome unchanged — the guard is byte-for-byte identical
(if (!ticket && !isContinuationStep) return <InvitationRequired />;); only the refusal's
presentation moved, and SignUp keeps the same forceRedirectUrl and signInUrl —
diff-verified (agent)PageHeader, and each of its three zero states renders
EmptyState — the batch's components imported from #907, not re-implemented. The spec
says "four"; the page has three (invitations, granted access, search-no-match) and all
three are covered. The miscount is the spec's, not the page's — this criterion was left
unticked on the PR through Build and Verify rather than counted up to four, and the fix
belongs in a spec revision, not in code/access has a loading.tsx mirroring the page — header skeleton, invite form, then two
TableCardSkeleton (rows 2/cols 3, rows 3/cols 4), built from console-skeletons.tsx,
which this run extended with the batch's first table shape — code-verified (agent)whitespace-nowrap and Table already wraps in an overflow-x-auto container, so the
console frame itself does not scroll horizontally — code-verified (agent); the width
smoke is the operator's line in verify.mdNOTICES banner intact, requireStaff() still gating the page — diff-verified
(agent): no action, notice or guard is in the diffThemeAwareClerkProvider plus semantic tokens; the Clerk card radius is spelled
calc(var(--radius) + 4px) because --radius-xl lives in @theme inline and never
resolves at runtime (code-review finding, fixed in 0bd580a) — code-verified (agent);
the two-theme visual pass is the operator's line in verify.mdVerify's gate carried no unresolved blocking finding: code review raised three, all fixed in
0bd580a (global Clerk card override scoped out of the UserButton popover; the unresolvable
radius token; EmptyState losing the h1 on the pre-console screens, fixed with an additive
titleAs); security review found nothing at HIGH or MEDIUM; production-readiness ran and found
no applicable surface.
What is not verified, stated plainly: no preview smoke was performed by the agent — Vercel deployment protection returns the whole preview behind an SSO redirect, so every DoD line in verify.md is unticked and attributed to the operator, and none of them came back ticked. The visual and responsive criteria above are code-verified only. Jamie's Ready to merge tick is what carries the merge past that gap; this line is the record that the gap existed.