Skip to Content

← All archived runs

Run: console-tenants-polish

run.md

Run: console-tenants-polish

  • branch: claude/console-tenants-polish-pipeline-9olkd2
  • pr: #909

02_define/output/spec.md

Spec: Tenant directory and detail polish

  • slug: console-tenants-polish
  • personas: Admin (Sustentus staff), Partner
  • touches: apps/console/app/(console)/tenants/, apps/console/components/tenant-status.tsx, apps/console/components/subscription-card.tsx
  • complexity: standard

Problem

The tenant pages are the console's daily driver for staff and the surface an invited partner spends the most time in, and they carry the console-ui-polish batch's irritations in their most concentrated form. Four tables — the directory, the deleted view, and the detail page's Users and Pending invitations — inherited the old narrow column and now sit in a shell that freed real width without anything using it; at tablet and half-screen widths they fall back to scrolling inside the table container, so a row's action can sit off-screen. Every zero state is a bare muted sentence, including the happy "no tenants have been deleted" case, which reads as a broken page rather than good news. None of the four data-backed routes has a loading state, so each one pops in. Staff ergonomics and partner-facing credibility both run through this surface, so cleaning it up advances Q2-2026 Objective 1 — Establish Product-Market Fit with Vendor Partners — under the Scale the bridge initiative. console-shell-sidebar established the frame and the brand baseline; this stub is the third of four that restyle the pages inside it.

Proposed change

A presentation pass over apps/console/app/(console)/tenants/** — directory, detail, new-tenant, deleted list and permanent-removal confirmation — plus the two components those pages own, tenant-status.tsx and subscription-card.tsx. Per Jamie's ruling (2026-08-27) this run is visual/UX only: every server action, form field name, hidden input, validation rule, notice code, notice wording and access check stays exactly as it is; only what renders changes.

  • Tables that use the freed width and behave at small ones. All four tables get a deliberate responsive treatment instead of the container's horizontal scroll: lower-priority columns fold into the primary cell, or rows stack, so that at tablet and half-screen widths every row's primary action — Open, Recover, Remove permanently, the role Select and Save, Remove, Revoke invitation — is reachable without scrolling sideways. Column order, headers and the data each cell shows are preserved; the two-line name/slug and date/window cells stay.
  • Designed empty states. Each zero state becomes a composed empty state (icon, heading, explanatory line, and the action already present where one exists) rather than a muted paragraph, keeping today's wording distinctions intact — the directory's four branches (past-the-end, no search match, staff "no tenants exist yet", partner "none assigned to you yet"), the deleted view's "No tenants have been deleted." framed as the reassurance it is, the detail page's "This tenant has no users yet.", "No invitations are waiting to be accepted.", the no-partner-assigned and no-partner-options lines, and the Clerk-organisation-missing anomaly card, which stays an explained anomaly and not a generic empty state.
  • Loading skeletons. A route-level loading.tsx for each tenants route that performs a server read before rendering — /tenants, /tenants/[tenantId], /tenants/deleted and /tenants/deleted/[tenantId]/remove — shaped like the page behind it, using the Skeleton primitive @sustentus/ui already exports. /tenants/new reads nothing and gets none.
  • Brand pass. Headings, section cards, forms, notice Alerts, and the badges in TenantStatus and SubscriptionCard are brought onto the brand typography, spacing and colour established by the shell stub (per the brand-guidelines skill), correct in both light and dark themes. The badge semantics are unchanged: lifecycle keeps lifecycleVariant, setup keeps its neutral | info | success mapping, subscription status keeps STATUS_VARIANT; the destructive emphasis on the removal path stays destructive.
  • Nothing else moves. The directory's search form and pagination are restyled where they sit but keep their existing markup contract (action="/tenants", method="GET", the search and page params, buildDirectoryQuery); the removal page stays a page with a typed-name confirmation checked server-side; SubscriptionCard stays read-only and still carries no money.

Context budget: no scope.md exists behind this batch — it was cut from Jamie's intake interrogation (2026-08-27) and the rulings are quoted in .icm/intake/console-ui-polish/ (breakdown.md + this stub), so there are no Q-n numbers to carry. The five tenant page sources and the two components were read in place of technical/architecture/repository-structure, to pin the exact behaviour this run must preserve.

Acceptance criteria

  • At tablet and half-screen widths the tenant directory shows no horizontal page scroll, and each row's Open action is reachable without scrolling the table sideways — lower-priority columns collapse into the name cell or the rows stack.
  • The same holds for the deleted-tenants table and the detail page's Users and Pending invitations tables: Recover, Remove permanently, the role Select with its Save, Remove and Revoke invitation are all reachable at those widths without horizontal scrolling.
  • Every zero state on these surfaces renders a designed empty state rather than bare muted text: the directory's four cases, the deleted view's "no tenants have been deleted" happy case, the detail page's no-users and no-pending-invitations cases, and the no-partner -assigned and no-partner-options cases — each keeping the message it shows today.
  • Navigating to /tenants, /tenants/[tenantId], /tenants/deleted and /tenants/deleted/[tenantId]/remove shows a loading skeleton shaped like the page instead of an abrupt pop-in.
  • Page headings, cards, forms, notice alerts and the TenantStatus / SubscriptionCard badges render on the brand typography and palette, correct in both light and dark themes, with lifecycle, setup and subscription-status badge semantics unchanged.
  • The new-tenant flow renders the same two fields with the same required, maxLength, type and helper copy, and the removal flow the same typed-name confirmation and Cancel — restyled only; both still post to the same server actions with the same field names.
  • Every existing notice code on the directory, detail, new-tenant, deleted and removal pages still renders its banner with the same wording and the same ok/bad tone.
  • A partner viewer still sees only the tenants carrying their id, still sees no Deleted link, no Lifecycle panel and no Assigned-partner panel, and cannot reach /tenants/deletedrequireViewer, requireStaff, scopeOf and isConsoleStaff are untouched.

Out of scope

  • Search, filtering, sorting or pagination changes to the directory — capabilities, not polish; they route through /pipeline scope. The existing search and pagination are restyled in place, and their query contract is preserved.
  • Any change to tenant actions, the server actions in tenants/**/actions.ts, the create/suspend/ delete/recover/remove lifecycle, or the recovery-window rules.
  • Access-control mechanics: proxy.ts, requireViewer/requireStaff, isConsoleStaff, the console-access marker, and the tenant scoping in scopeOf/listForConsole/findForConsole.
  • New exports or changes to packages/ui — its Skeleton, Table, Card and Badge primitives are consumed as-is, so this run cannot collide with the parallel stubs 2 and 4.
  • The dashboard, activity and notification surfaces (console-dashboard-activity-polish) and the auth, invitation-accept and no-access surfaces (console-auth-access-polish).
  • Making SubscriptionCard writable, or surfacing any amount from console-billing.ts.

Open questions

  • none — (non-blocking note) whether a table collapses by column priority or stacks into rows at small widths is Build's to pick per table, within the shell stub's apps/web-derived patterns; the reachability criteria above are the requirement.

03_build/output/notes.md

Build notes: console-tenants-polish

  • commits: feat: console-tenants-polish — responsive tables, designed empty states, loading skeletons (code), chore: console-tenants-polish — build notes (this file)
  • ci: settled via ci-status.sh on the pushed head — Build hands off only on GREEN; the PR's checks on that commit are the durable record.

What changed

New console-local components (apps/console/components/) — none of them go in packages/ui: the spec excludes it so this run cannot collide with the parallel stubs 2 and 4, and each is consumed by exactly one app. Two of them turned out to collide with stub 2 anyway — see the merge section below.

  • empty-state.tsx: icon, title, the sentence the surface already said, and the action it already offered. The description is the old copy verbatim — the wording is what distinguishes cases that look alike (a partner with no assignments from a platform with no tenants; a page past the end from a search that matched nothing), so only the frame is new.
  • page-header.tsx: back link, title, description, a leading slot for the tenant avatar, an actions slot, and children for status badges. Four of the five tenant pages had the same hand-rolled header; now they render one.
  • notice-alert.tsx: the ok | baddefault | destructive mapping, in one place. Three pages carried their own copy of that ternary.
  • detail-field.tsx: a labelled value, shared by the Details and Subscription cards — they sit directly above one another, so their labels had to match exactly.
  • console-skeleton.tsx: PageHeaderSkeleton, TableSkeleton, SectionCardSkeleton, consumed by all five loading.tsx files.

Responsive tables. All four tables fold lower-priority columns into the primary cell below lg rather than relying on the table container's horizontal scroll: directory (Status → name cell), deleted (Deleted date + window → tenant cell), Users (Email → name cell), Pending invitations (Role + Invited → email cell). Action cells gained align-top so a folded row's button stays beside the name it belongs to, and the deleted view's two buttons stack full-width below sm.

The fold is at lg, not md, and that is the load-bearing decision: below 768px the shell's sidebar is an offcanvas drawer so a page gets the whole viewport, but between 768 and 1024 it is still the expanded desktop sidebar — roughly 256px of it — so a "tablet" viewport leaves the content box under 500px. Folding at md would have looked right in a browser resize and shipped the exact overflow the stub was raised about.

Empty states. Directory (four cases, with a heading that separates "list ran out" from "directory is empty"), deleted view, tenant Users, Pending invitations, and the two assigned-partner cases. The tenant whose Clerk organisation is missing became a destructive Alert rather than an empty state — the spec calls for it to stay an explained anomaly, and an empty state would have read as "no users yet" for a tenant whose roster simply cannot be reached.

Loading skeletons. loading.tsx for /tenants, /tenants/[tenantId], /tenants/deleted and /tenants/deleted/[tenantId]/remove, each shaped like the page behind it.

Brand pass. Headings, cards, forms and banners onto the shared tokens and the Typography scale the shell established; Select triggers go full-width on narrow rows; the removal warning gains an icon and a title. Long identifiers (clerkOrgId, email addresses) wrap instead of forcing the row wide.

Deliberate small fixes made in passing, both presentational and both in files this run already rewrites: <Link><Button> became <Button asChild><Link> on the deleted view's "Remove permanently" and its pager (a <button> inside an <a> is invalid HTML and the directory already used asChild); SubscriptionCard's three fields move from a lopsided 2-column grid to sm:grid-cols-2 lg:grid-cols-3, matching the Details card above it.

tenant-status.tsx is unchanged, deliberately. It renders two Badges whose variants come from the shared semantic tokens, so it already satisfies the brand criterion; the only thing left to change there is the shared badge palette itself, which the spec excludes. See Notes for Verify.

No tests. The diff is page markup and presentational components — CONVENTIONS.md → Testing → What not to test lists page markup explicitly, and no pure logic changed (console-lifecycle.ts, console-roles.ts, subscription-labels.ts, console-tenants.ts are all untouched, and their existing unit tests still describe them accurately).

Context budget: within the Inputs table — the spec's touches: files, /CONVENTIONS.md, apps/console/AGENTS.md, the brand-guidelines skill, and the @sustentus/ui primitives the pages consume (skeleton, badge, alert, table, lib/variants.ts, globals.css) to confirm what was already available and theme-correct. The architecture docs pages were not needed.

Acceptance criteria status

  • Directory readable at tablet/half-screen — Status folds into the name cell below lg; two columns (name + Open) at every width under it, and the shell already clamps page-level horizontal scroll.
  • Deleted, Users and Pending invitations the same — each folds to two or three columns below lg; Recover, Remove permanently, the role Select with Save, Remove and Revoke invitation all sit inside the content box at 768 and above.
  • Designed empty states — all nine, each keeping today's message as its description.
  • Loading skeletons — all four named routes, plus /tenants/new (see the deviation below).
  • Brand pass, both themes — headings/cards/forms/alerts on the shared tokens; badge semantics (lifecycleVariant, the setup map, STATUS_VARIANT) untouched.
  • New-tenant and removal flows restyled only — same two fields with the same required, maxLength, type and helper copy; same typed-name confirmation and Cancel; same server actions and field names.
  • Every notice code renders as before — the five maps are byte-identical in wording and tone; only the rendering moved into NoticeAlert. The one exception is the removal page's name-mismatch message, which keeps its own inline Alert because it emphasises the exact string to type and so carries markup rather than being a plain sentence.
  • Partner scoping untouchedrequireViewer, requireStaff, scopeOf, isConsoleStaff, the staff-gated Deleted link, Lifecycle panel and Assigned-partner panel are unchanged; no read was added, widened or removed.

Merging console-dashboard-activity-polish (stub 2)

Stub 2 shipped to main while this run was in Build, and it had independently created its own empty-state.tsx and page-header.tsx in apps/console/components/ — an add/add conflict on both files, plus a near-miss console-skeletons.tsx (plural) against this run's console-skeleton.tsx (singular), which also collided on the exported name PageHeaderSkeleton. Keeping packages/ui out of scope stopped the two runs colliding there; it did not stop them colliding in the app, because both needed the same two components and neither could see the other's branch.

Resolved by taking main as the baseline and folding this run's needs into it, never the reverse:

  • empty-state.tsx — main's version adopted wholesale. Its API is the better one: the icon is passed as a component (icon={Users}) so the component owns its own sizing, and it has a compact mode. Every call site in this run was converted to that form, and the assign-partner empty state now uses compact instead of the hand-rolled className="py-6" it had.
  • page-header.tsx — main's heading block kept verbatim, with this run's four extras (backHref/backLabel, actions, leading, children) added as opt-in props. A caller passing only title and description returns the bare block with no wrapper, so main's four existing call sites render byte-identically; description became optional for the removal page, which has none.
  • console-skeleton.tsx deleted; its TableSkeleton and SectionCardSkeleton moved into main's console-skeletons.tsx, and PageHeaderSkeleton gained withBack/withActions, defaulting off so main's no-arg <PageHeaderSkeleton /> is unchanged. Two files a letter apart exporting the same symbol is precisely the rot CONVENTIONS.md → Keeping the codebase lean warns about, so consolidating was not optional.

Notes for Verify

  • One deviation from the spec, stated plainly. The spec says /tenants/new reads nothing and gets no skeleton. It gets one. Next's loading.tsx boundary covers nested segments, so tenants/loading.tsx would have stood in for it and shown a directory table skeleton on the way to a two-field form. requireViewer() also goes to Clerk on every request, so there is a real moment to fill. Reverse it by deleting new/loading.tsx if you would rather have the inherited shape.
  • Badges in dark mode. badgeVariantConfig's semantic variants are bg-success-100 text-success-800 with no dark: override, and those scale steps are defined once in @theme rather than being redefined under .dark — so a status badge is the same light-tinted chip in both themes. It is legible (light fill, dark text) and it is how every badge in every app already renders; it is not something this run introduced. Changing it means changing the shared palette, which the spec puts out of scope — worth a look on the preview, and a /pipeline tweak if you dislike it.
  • Check the fold boundary on the preview at 800–1000px wide, with the sidebar expanded and then collapsed to the icon rail. That band is where the sidebar eats the most content width and is the case the lg choice above is built around.
  • The empty states now look like stub 2's, not like the ones described earlier in these notes: adopting main's EmptyState replaced the dashed-border frame with its muted circular icon and its type scale. That is the intended outcome — one zero state across the console — but it means the tenant empty states are worth eyeballing beside the dashboard and activity ones on the preview, since this is the first time the two sets render side by side.
  • PageHeader and console-skeletons.tsx are now shared with stub 2's shipped surfaces. The extras are additive and the no-extras path returns main's exact block, but the dashboard, activity, notifications and email-delivery pages are worth a glance to confirm their headings and loading shapes are unchanged — they are the callers this run did not set out to touch.

04_verify/output/verify.md

Verify: console-tenants-polish

  • ci: GREEN on 9145f53 — settled via ci-status.sh after the review fixes; the head carrying this file is re-verified at hand-off and named there.
  • previews smoked: none by the agent — see the DoD note below. tenant-management (apps/console) and web built for this commit; agentic-interface, demo, docs, help-centre, marketing, storybook were reported skipped for this diff and so have no preview to demonstrate anything on. The console preview is the only one this change is visible in, and it is the one that built.
  • production-readiness: not required — no database, auth, payments or env-var change in the diff. Verified rather than assumed: the only added line matching process.env|mongoose|Schema|requireStaff|requireViewer|isConsoleStaff|scopeOf|clerkClient|stripe|payment is a code comment. No new process.env read, so nothing to add to turbo.jsonglobalEnv.
  • code-review: medium (the effort complexity: standard dictates; the CI Claude review is disabled — its workflow run reports skipped, so /code-review was run instead). Two findings, both fixed on branch in 9145f53.
  • security-review: run — no findings. The trigger is arguable (the diff touches no auth mechanics or route policies) but it edits lines that render PII in an app whose entire risk model is tenant isolation, so it was run rather than argued away.
  • playwright: TODO — manual DoD smoke performed instead.

DoD smoke (on the preview — each line says who verified it)

The agent demonstrated nothing on the preview, and does not claim to. Every (console) route is behind Clerk, and the preview deployment additionally sits behind Vercel deployment protection: an unauthenticated request to /tenants and /tenants/deleted returns a vercel.com/login SSO redirect, so not even the sign-in redirect is reachable from here. The agent-run half of this smoke is therefore limited to tracing each criterion's code path in the diff — which is what the lines below record. Every criterion needs an operator on the preview before this gate can pass.

  • Directory readable at tablet/half-screen, Open reachable — agent traced: Status TableHead/TableCell carry hidden lg:table-cell, with a folded TenantStatus at mt-2 lg:hidden inside the name cell; action cell text-right align-top. Operator: confirm at ~800px and ~1000px with the sidebar expanded, then collapsed to the icon rail.
  • Deleted / Users / Pending invitations the same — agent traced: deleted Deleted column folds (date + window label reproduced at lg:hidden), its actions stack flex-col … sm:flex-row; Users Email folds; Pending Role + Invited fold into the email cell. Recover, Remove permanently, the role Select + Save, Remove and Revoke invitation are all outside the folded columns. Operator: confirm each is clickable without scrolling the table sideways.
  • Nine designed empty states, wording unchanged — agent traced all nine call sites and confirmed each description is the exact sentence the surface showed before. Operator: needs a tenant with no users, a tenant with no pending invitations, an empty search, a page past the end, and an empty deleted list to see them.
  • Loading skeletons on the five routes — agent traced: loading.tsx exists for /tenants, /tenants/[tenantId], /tenants/deleted, /tenants/deleted/[tenantId]/remove and /tenants/new. Operator: navigate between them and confirm no abrupt pop-in, and that each skeleton is shaped like the page behind it.
  • Brand pass correct in both themes — agent traced: headings/cards/forms/alerts render from the shared tokens; badge semantics untouched (tenant-status.tsx is not in the diff, and lifecycleVariant / the setup map / STATUS_VARIANT are unchanged). Operator: toggle light/dark on every tenant surface. See the badge note in Findings.
  • New-tenant and removal flows restyled only — agent proved by diff against main: the set of name=/id=/type=/required/maxLength/action=/autoComplete attributes is identical on both pages, with one intended exception — Cancel became a single <a> via Button asChild, so the type="button" that stopped a nested <button> submitting the form is no longer needed. Operator: submit both forms, including the wrong-name case.
  • Every notice code renders as before — agent proved by diff against main: all three NOTICES maps are byte-identical in codes, wording and tone; the only textual difference is the shared NoticeTone alias replacing the inline "ok" | "bad". Operator: trigger a representative ok and bad notice on the detail and deleted pages.
  • Partner scoping untouched — agent proved structurally: every requireViewer/requireStaff/isConsoleStaff/scopeOf/service-scoping expression is identical to main (the only two textual differences are a JSX brace moving as a ternary became a prop, and partnerId gaining an <AlertTitle> wrapper); the Lifecycle card (L391–444) and Assigned-partner card (L446–526) are each fully enclosed by their {staff ? … : null} guard; readConsoleRoster/readConsoleAccountById remain behind staff ?. Operator: this is the one to actually test — sign in as a partner and confirm only assigned tenants are listed, no Deleted link, no Lifecycle or Assigned-partner panel, and /tenants/deleted refuses.
  • auth: partner + staff sign-in and reach the console (operator)
  • payments: not touched — SubscriptionCard stays read-only and still surfaces no amounts (agent, by diff)
  • notifications: none expected — this run adds no notification path (agent, by diff); operator to confirm none regressed if the bell is exercised

Findings & cleanup

  • break-all on the email cells was inertTableCell in packages/ui/src/base/table.tsx hard-codes whitespace-nowrap, which tailwind-merge does not drop for a break-* class and which white-space inherits to the Typography children. Long addresses would still have forced the row wide and scrolled the table sideways — the precise behaviour the column fold exists to remove, so this would have silently defeated acceptance criterion 2 on the surface it matters most. Fixed on branch (9145f53): whitespace-normal paired at all three sites.
  • SectionCardSkeleton did not match the card it stands in for — it drew lines * 2 placeholders in sm:grid-cols-2 while the Details and Subscription cards render three fields in sm:grid-cols-2 lg:grid-cols-3, so above 1024px the placeholder reflowed differently from the content replacing it. Fixed on branch (9145f53): takes fields (default 3) and mirrors the real grid.
  • Stub 2 collision, resolved in the merge (41580f7)console-dashboard-activity-polish shipped to main mid-Build having independently created its own empty-state.tsx and page-header.tsx, plus a console-skeletons.tsx that collided with this run's console-skeleton.tsx on both filename and the exported PageHeaderSkeleton. Resolved by taking main as the baseline: its EmptyState adopted wholesale (it is absent from this branch's diff, which is the proof), its PageHeader block kept verbatim with this run's extras added opt-in, and the duplicate skeletons file deleted into main's. Accepted with a note for Ship: keeping packages/ui out of scope prevented a collision there but not in the app — parallel stubs touching one app will keep meeting like this, and stub 4 (console-auth-access-polish) is still to come against these same shared components.
  • Badge palette in dark mode — accepted, not fixed. badgeVariantConfig's semantic variants are bg-success-100 text-success-800 with no dark: override, and those scale steps are defined once in @theme rather than being redefined under .dark, so a status badge is the same light-tinted chip in both themes. Legible, pre-existing across every app, and changing it means changing the shared palette — which this run's spec excludes. Worth a /pipeline tweak if the operator dislikes it on the preview.
  • CI trigger gap during Build — resolved, worth watching. GitHub created no workflow run at all for the first code push (5f42cf0): neither Quality nor Pipeline, though both had fired on the Define pushes 25 minutes earlier from the same session. The gate was obtained by dispatching quality.yaml through its own declared workflow_dispatch trigger (full checks path, confirmed step-by-step: install, format, lint, typecheck, tests), and the stage:build label by project-labels.sh, its documented manual fallback. No empty commit was pushed. Every push since has triggered normally, so this looks transient — but if it recurs, Verify and Ship both gate on a settled GREEN that would never arrive.

Context budget: within the Inputs table. The extra reads were packages/ui/src/base/table.tsx and src/lib/variants.ts — needed to confirm the two findings above are real rather than plausible — and origin/main's versions of the four collided component files, to resolve the merge.

05_ship/output/changelog.md


title: The console's tenant pages, refreshed date: 2026-08-28T00:00:00Z personas: [admin] slug: console-tenants-polish pr: https://github.com/sustentus/sustentus/pull/909

The console's tenant pages, refreshed

The dashboard and activity surfaces were refreshed earlier this week. The tenant pages are next — the directory, a tenant's detail page, the deleted list and the permanent-removal confirmation. Nothing new to do here and nothing new on show: the same tenants, the same actions, the same people who could see them before.

Tables stop running off the side of the screen. The directory, the deleted list, and a tenant's users and pending invitations used to scroll sideways on a half-screen window or a tablet, which could put a row's Open, Recover or Revoke out of reach. The columns that matter least now fold underneath the name they belong to, so every row's action stays where you can click it. Nothing is lost, only moved — and a long email address wraps instead of stretching the row.

Empty is a state, not a blank. A tenant with no users yet, a search that matched nothing, a page past the end of the list — each now says what it means and what would fill it. The deleted list's "no tenants have been deleted" reads as the good news it is, rather than as a page that failed to load.

Pages arrive in shape. Opening the directory, a tenant, the deleted list or a removal confirmation now shows the outline of what is coming, which fills in as it loads. Nothing jumps into place after a pause.

It looks like Sustentus. Headings, cards, forms and the notice banners follow the same type and colour as the rest of the product, in both light and dark. Status, setup and subscription badges keep exactly the meanings they had.

Everything else is where you left it. Creating a tenant asks for the same two things, permanent removal still asks you to type the tenant's name, and a partner still sees only the tenants assigned to them.

The sign-in and invitation screens are the last of these.

05_ship/output/investor-update.md

Tenant pages that fit any screen

Who it's for: Sustentus staff and partners What shipped: The console's tenant pages fold their columns instead of scrolling sideways, and gained empty states, loading skeletons and brand styling in both themes. Why it matters: Every row action stays reachable at tablet width — Scale the Bridge, Objective 1: Establish Product-Market Fit with Vendor Partners.

Same data, same actions, same access.

Dig deeper: https://github.com/sustentus/sustentus/pull/909 · https://help.sustentus.com/changelog/2026-08-28-console-tenants-polish

05_ship/output/release.md

Ship: console-tenants-polish

  • pr: #909 · merge: authorised — Ready to merge ticked by Jamie; this commit rides the squash
  • CI: settled via ci-status.sh on the head this file is pushed with, established after this stage's last push — GREEN is the only verdict the merge proceeds on. The run's earlier heads were green too: 315edb3 carried the verify record and built the console preview (tenant-management) plus web.
  • technical docs: no technical docs impactapps/docs/app/technical/applications/console/page.mdx documents the console's routes, access model, scoping rules and data, none of which this run touches. Checked rather than assumed: the page makes no claim about table layout, empty states, loading behaviour or component structure (grepped for each), so there is nothing in it this presentation pass makes stale. The Routes table's five /tenants* rows describe what each route does, and all five still do it.
  • business docs: no business docs impact — the console is the internal tenant-management surface, never tenant-facing. No service-journey step and no feature-role-matrix entity is touched, and no persona gained or lost a capability. (Same finding as the two sibling stubs; settled at the intake cut.)
  • release notes: both — changelog entry 2026-08-28-console-tenants-polish (in this PR) + ship note below.
  • sent: nothing sent at commit time — the ship note rides this merge and ship-note.yaml dispatches it to #product-update when the PR merges. Both Dig deeper links are filled (PR and the live changelog URL); no placeholder remains for its guard to catch.
  • close-out: archive .icm/runs/console-tenants-polish/ to apps/docs/archive/pipeline-runs/ and the epic with it. console-auth-access-polish (#910) shipped and closed out while this PR sat at the gate, so all four stubs are in .icm/intake/console-ui-polish/_done/ and this is the batch's last live run — .icm/intake/console-ui-polish/ is archived to apps/docs/archive/pipeline-intake/ in the same step. (An earlier draft of this line said one stub was still in flight; that stopped being true when #910 merged.)

Acceptance check (vs spec)

  • Directory readable at tablet/half-screen widths with Open reachable — Status head and cell carry hidden lg:table-cell with a folded TenantStatus inside the name cell. Traced in the diff at Verify.
  • Deleted, Users and Pending invitations tables the same — Deleted, Email, Role and Invited fold; Recover, Remove permanently, the role Select with Save, Remove and Revoke invitation all sit outside the folded columns. Traced in the diff at Verify.
  • Nine designed empty states, each keeping today's wording — all nine call sites traced and each description confirmed to be the exact sentence the surface showed before. The Clerk-organisation-missing case stays an explained anomaly, not an empty state.
  • Loading skeletons shaped like the page — loading.tsx on /tenants, /tenants/[tenantId], /tenants/deleted and /tenants/deleted/[tenantId]/remove. One deliberate deviation: /tenants/new also gets one, which the spec said it would not. Next's loading.tsx boundary covers nested segments, so without it the directory's table skeleton would stand in for a two-field form. Reversible by deleting that one file.
  • Brand pass across headings, cards, forms, alerts and badges in both themes — rendered from the shared tokens; badge semantics untouched (tenant-status.tsx is not in the diff, and lifecycleVariant, the setup map and STATUS_VARIANT are unchanged). Noted, not fixed: the semantic badge variants have no dark: override, so a status chip is light-tinted in both themes. Pre-existing across every app; fixing it means changing the shared palette, which this spec excludes. Worth a /pipeline tweak.
  • New-tenant and removal flows restyled only — proved by diff against main: the set of name=/id=/type=/required/maxLength/action=/autoComplete attributes is identical on both pages, with one intended exception (Cancel became a single <a> via Button asChild, so the type="button" that stopped a nested <button> submitting is no longer needed).
  • Every notice code renders as before — proved by diff against main: all three NOTICES maps are byte-identical in codes, wording and tone; the only textual difference is the shared NoticeTone alias replacing the inline "ok" | "bad".
  • Partner scoping untouched — proved structurally: every requireViewer/requireStaff/isConsoleStaff/scopeOf/service-scoping expression is identical to main; the Lifecycle and Assigned-partner cards are each fully enclosed by their {staff ? … : null} guard; the roster reads stay behind staff ?. Confirmed by the security review, which found nothing.

How these were verified. All eight were established by code-path tracing in the diff plus the code review (two findings, both fixed on branch in 9145f53) and the security review, recorded in 04_verify/output/verify.md. The agent demonstrated none of them on the preview: every (console) route is behind Clerk and the preview additionally sits behind Vercel deployment protection, which answers an unauthenticated request with a vercel.com/login redirect — so the whole on-preview half of the Definition of Done was the operator's, and every DoD box in verify.md was deliberately left unticked. Ticking Ready to merge is Jamie's confirmation that it was done.

The parallel-stub collisions, and how they resolved. This run met the rest of its batch twice in the same file, and both times the resolution was to take main as the baseline.

  1. Stub 2, mid-Build. console-dashboard-activity-polish shipped having independently created its own empty-state.tsx and page-header.tsx, plus a console-skeletons.tsx that collided with this run's console-skeleton.tsx on both filename and the exported PageHeaderSkeleton. Resolved in 41580f7: main's EmptyState adopted wholesale, its PageHeader block kept verbatim with this run's extras opt-in, and the duplicate skeletons file deleted into main's.

  2. Stub 4, at the gate. console-auth-access-polish (#910) shipped while this PR waited on Ready to merge, adding TableProps, TableRowsSkeleton and TableCardSkeleton to that same console-skeletons.tsx — a content conflict against this run's TableProps, TableSkeleton and SectionCardSkeleton. Resolved by keeping main's three verbatim and renaming this run's to TenantTableSkeleton / TenantTableProps, so nothing shipped changes shape and the file no longer implies there is one canonical table skeleton. The two are genuinely different: the tenant tables share width evenly because they fold columns below lg; the access tables are fixed-width and do not.

    Resolving it surfaced a real defect in this run's own PageHeaderSkeleton. It wraps the title and subtitle in a flex row to make room for the opt-in action buttons, which left the text block auto-width — so the subtitle's w-full max-w-md resolved against a shrink-to-fit parent and collapsed to roughly the title's width on every caller passing no actions. That is four shipped pages (activity, notifications, notification delivery, and #910's new access page), and it would have been this run silently narrowing stub 2's and stub 4's skeletons. Fixed in the merge with min-w-0 flex-1 on the text block.

Context budget: within the Inputs table. technical/applications/console/page.mdx was read and grepped to establish the no-impact finding rather than assert it; the two sibling stubs' archived release.md and changelog pages were read for the batch's established voice and docs-impact precedent.