Skip to content

Frontend surfaces

What the user sees and can do. React/TypeScript app in frontend/src/; the product is branded Orbit inside Luniq.

App structure

  • App.tsx — root router + providers (ErrorBoundary → QueryClient → Auth → Tooltip → Router → Workspace → Notification). Route guards: OnboardingRoute, ProtectedRoute (auth + ≥1 workspace), RootRoute, WorkspaceUrlSync. RootRoute (/) is THE landing decision (2026-08-27), and every entry point funnels through it — /login, /register (auto-confirm), /email-confirmed, the Supabase confirm link's emailRedirectTo, the agency-invite accept, and /home's own effect. An ACTIVE agency → /agency, with or without sites: the hub is the agency's home from the first login and carries its own empty state (EmptyHub), which is where the pre-workspace work will live. Everyone else with a workspace (guests, a suspended agency's people) → /w/{id}; a signed-in user with no workspacenoWorkspaceTarget() = /home. Two deliberate non-participants: password reset returns to /login (a credential change is not a session start), and a workspace invite accept goes straight to /w/{id} (a guest has no hub, and an invite to one site should open that site). (The pending-invite-token safety net that used to live there was removed 2026-08-11 — invite signups go through POST /api/invite/signup (lib/invite.ts) and are born email-confirmed, so Join signs in and auto-accepts in one pass.)

  • Telemetry reporters (lib/error-reporter.ts + lib/engagement-reporter.ts, both installed in main.tsx) — batched, bounded, never-throw, silent-on-failure. The engagement reporter captures pageviews (via components/EngagementListener.tsx, mounted above the Routes; paths normalized, UUIDs → :id), clicks on [data-track] elements only (NavLink auto-tags nav as nav.<path>; chat.send, article.publish/.republish tagged by hand), rage clicks, and 20s heartbeats counting ONLY visible + input-active seconds — that gating IS the time-on-platform metric, do not loosen it. Both reporters stamp the same lib/session.ts id, so a client error joins its session in the internal dashboard's drill. A 410 from the ingest (backend DISABLE_APP_EVENTS) self-disables the reporter for the session.

  • shell/AppLayout.tsx — the authenticated shell at /w/:workspaceId/*: fixed sidebar + header (breadcrumb, Ask Orbit button, theme toggle, user popover) + page beside the docked OrbitPanel. Whole subtree keyed on workspace id (switching fully remounts). Gate/readiness/warmup banners at the top (WorkspaceReadinessBanner = THE ONE "finish setup" nag; use-content-settings.ts is a pure consumer of the enablement setup verdict, use-setup-complete-map.ts is deleted — every list surface reads the server's verdict). components/WorkspaceState.tsx is THE workspace-state vocabulary (2026-08-18): the server's automation object ({ runs, ready, paused, reason } — one gate, workspace/readiness.js) → workspaceRunState (ordered suspended → archived → paused → setup → running; the client adds NO rule, only the order and the words) → WorkspaceStatePill, one pill with label + icon + tone + a tooltip carrying what it means, rendered in the switcher rows, the agency workspace rows, and the sidebar's workspace card (where it takes the domain line, and rides the favicon as a same-toned dot in the collapsed rail). Running renders nothing — silence is the good state. It replaced a bare amber dot that only knew ready, so a PAUSED workspace (nothing runs at all) looked healthy in both lists and said so only inside its own chat; five of fifteen live workspaces were in that state. components/WorkspaceWarmupBanner.tsx is THE ONE post-onboarding setup banner (2026-08-16): it polls the health overview (first audit + backlinks, running.trigger === 'onboarding') AND the site-context run (/api/modules/pages/:ws/run, triggers onboarding / onboarding-drain — the key-page drain then the full GSC pull), composes one sentence from whichever is running (reading your pages (N pages read) · fetching your Google data · running your first site health audit…), self-starts on a young (<24h) workspace via a 15s poll and self-clears within one 5s tick of the jobs finishing or being stale-reclaimed, and invalidates the Pages list when the site run completes. The per-page "indexing" chip on SitePages was REMOVED with it — the GSC pull's URL-inspection half (500 URLs, 3 at a time, 15s timeouts) legitimately runs 5–40 minutes on a fresh site, and a static "Fetching Google data" pill on the list read as stuck; a manual Refresh-from-site keeps its own progress in Settings. The user popover holds the two CROSS-workspace doors — the agency by name (/agency — the agency's HOME since 2026-08-27; hidden for guests, who have no agency) FIRST, then Account (/account), then Sign out. Org above person: it is the wider scope and the way out of this workspace, and it matches the tab order in PersonalLayout. It is no longer the only way back: the sidebar logo links to /agency for agency staff (LogoSlot in AppSidebar, tooltip "Back to {agency}"), inert for a guest whose own site IS their home — Webflow's W, and the thing a person clicks by reflex. The user button itself carries the agency's mark as a badge on the person icon (bottom-right, ring-sidebar so it reads as attached), so one control answers "who am I" and "whose agency is this" at once; staff only, since a guest has no agency.

  • components/agency/AgencyMark.tsx — the agency's own square mark for surfaces that point AT the agency (the /agency tab in PersonalLayout, the user-button badge and the popover entry in AppLayout), replacing the generic Building2. It resolves from the CALLER'S agency (useAgencyBranding), not the workspace's, because those surfaces mean your agency; renders through BrandLogoArt so an upload looks identical here and in the sidebar (initial-on-a-tile fallback included). Falls back to Building2 while the brand loads and when nothing was uploaded — the tab is never empty, and a white-labelled agency never sees Luniq's mark flash. Corners are pinned to 2px (overriding BrandLogoArt's rounded-md, which is drawn for the 32px sidebar tile and reads as a dot at 10–16px). pageShell is the ONE lookup deciding how the page card wraps its route — blog editor (full-bleed grid, editor + chat own their scroll; the editor's scroll container re-adds the shell's p-3 sm:p-5 itself so its totals match the signal page — 2026-09-03), Orbit home (scrollable, no inner pad — the greeting/composer centre themselves and the backdrop needs the card's own corners to bleed out of), everything else (padded + scrollable). Site-page detail left the full-bleed list the same day: it rides the padded default plus its own py-10, the exact two layers a signal page sits in, retiring its hand-tuned pt-14. It was three nested ternaries in the JSX with the padded default buried last; a fourth variant is what forced the extraction.

  • shell/PersonalLayout.tsx — the chassis for the cross-workspace surfaces /account and /agency/*, mounted OUTSIDE the workspace tree (auth-only, not ProtectedRoute: a user with no workspace still needs their account page). They edit nothing workspace-scoped, and inside AppLayout they would re-mount on every workspace switch. A settings page lives at the scope it edits — AGENCY_MODEL_PLAN §3. Mirrors AppLayout's frame: h-screen column, fixed 14-unit header on the sidebar surface, page in a bordered card that owns its own scroll (the body never scrolls), symmetric padding since there is no sidebar to hug. Header left→right (2026-08-27): for an ACTIVE agency, its tab wearing the agency's NAME (→ /agency, the home — and no Back button: the home is the way back), then Account; guests and not-yet-approved agencies have no home here, so they keep Back (to the active workspace, or /home when none) + Account (sidebar-accent selected state, same language as AppLayout's Signals entry); right: theme toggle + the same user button as the app — the tabs already cover Account and the agency, so the only thing behind it is Sign out.

  • components/BrandLogo.tsx — the chassis logo: the agency's if uploaded, Luniq's otherwise, read from useWorkspaceContext().branding (workspace-resolved, so a guest of a white-labelled agency sees the agency's logo). Light/dark is automatic: lib/logo-treatment.ts is the ONE rule (resolveLogo), fed by the tone/mono measured at upload — none when the artwork has contrast, invert when it is at risk AND single-colour (the trick Luniq's own wordmark uses, applied only when safe), chip when it is at risk and coloured (change the background, never the artwork). An explicit per-theme upload always wins. The square mark is exempt from the whole rule — it is a tile, so it fills its container with object-cover and the container clips the rounded corners; running it through the wordmark's rule painted a black chip behind light-toned icons. logoBoxClass(aspect) picks the render height from the logo's measured SHAPE — a long wordmark is width-limited by the 208px sidebar so it gets h-6, while a compact/stacked lockup gets h-8 with a tighter width cap rather than being shrunk to a wordmark's size. BrandLogoArt is exported so the branding settings page previews through the exact same component instead of approximating it. A wordmark without a square mark falls back to the agency's initial on a tile, never Luniq's mark next to someone else's wordmark.

  • components/WorkspaceFavicon.tsx — THE workspace site-icon, extracted from the switcher where three near-copies had drifted. Three stages: stored favicon_url → Google's favicon service for the domain → the initial on a solid tile; stage resets when the workspace changes so a switch never shows the previous site's icon. Used by the switcher, both sidebar footers, and the /agency home tiles.

  • shell/AppSidebar.tsx — primary nav; collections injected dynamically under Content; entries can be soon ("Soon") or module-disabled ("Disabled", via useModuleEnablement). Footer: workspace switcher + Support. Structure is flat, not a tree: a group (Content, Performance) renders as a section label that toggles — text at the icon column plus a rotating chevron — and its entries sit at the SAME indent as top-level items, so one icon column aligns the entire rail. That is why NavChild carries its own icon in nav.ts; there is no left rail or indent to signal nesting. ONE renderEntry draws both top-level items and section entries (identical row, identical states), and a plain item that follows a section gets the same top break the section labels get, so Settings can't read as a Performance entry. Sections use Radix Collapsible with the collapsible-down/up keyframes (out-quart, 200ms) and persist collapsed/expanded per section in localStorage under sidebar-sections — a section that reopened on every reload made collapsing pointless. The icon rail shows the same tree, not a reduced one: every entry is present as an icon (so each has its own tooltip), the section LABEL collapses to zero height and fades, and a section the user closed stays closed in the rail. Nothing is conditionally unmounted between the two states — labels give up width and opacity in CSS on the same 200ms out-expo curve as the rail, so expanding/collapsing reads as one movement instead of text popping at frame 0 while the width is still animating. Rail padding is one value (8px) across header, nav group and footer, and the collapsed button carries mx-auto so nav icons sit on the same centre line as the brand mark above and the footer icons below. Drag-to-resize on the right edge (152–400px since 2026-08-31 — the floor was 200px, barely under the 208px default, so the drag had room to grow and almost none to shrink; 152px still truncates the nav labels cleanly and stays above the 3.5rem icon rail, which is what someone wanting less than that actually wants), persisted in localStorage under sidebar-width:<userId>, hidden while the rail is collapsed since that width is fixed.

  • components/ui/sidebar-resize.tsx — the ONE resize implementation, shared by AppLayout and DocsLayout so both shells behave identically: useSidebarResize(key, defaultPx?) owns the width (spread its providerProps onto SidebarProvider, which sets --sidebar-width) and <SidebarResizeHandle> renders the pointer-capture handle inside <Sidebar>. Transitions are suppressed during a drag so the width tracks the pointer instead of easing behind it; the width is written to localStorage on pointer-up, not on every move. The stored key is scoped by user id (2026-08-31) — it was bare, so two people signing into the same machine inherited each other's sidebar; a signed-out reader (the public docs shell) keeps the unscoped key. The auth user resolves after the first render, so an effect re-reads whenever the key changes, which is also what makes switching accounts in place reset to the default instead of keeping the previous person's width. SIDEBAR_DEFAULT_PX (208, the 13rem the Sidebar primitive ships) is exported from here and lives beside the clamp, so defaultPx is optional and the app shell cannot drift from it; the docs shell deliberately passes 256, a wider rail for reading nav. Both reads and writes are try/caught for private-mode storage.

  • hooks/use-theme.ts — the theme is ONE shared external store (useSyncExternalStore), not per-component useState. It was the latter, which gave every caller a private copy: the header's toggle flipped <html> and its own state while the sidebar logo and everything else kept the stale value until a reload — and each instance re-applied its theme in an effect, so a remount could flip the page back. An external store rather than a context provider, because consumers live in unrelated trees (login, docs, maintenance) that no single provider wraps, and because the truth is the DOM class + localStorage — browser state, not React state. index.html applies the stored value pre-mount to avoid a flash; the store reads the same key, and a storage listener keeps two tabs in agreement.

  • Density system — lookup table of every knob and recipe: Design system; this entry is the history. The type scale below text-xs is three role tokens (text-label 11px, text-hint 10px, text-fine 9px in tailwind.config.ts), swapped in for 550+ hand-typed text-[Npx] on 2026-08-29; scripts/check-ui-tokens.mjs (first step of npm run build) ratchets the remaining raw px sizes and raw h-6…h-10 heights down against scripts/ui-tokens.baseline.json. cn() (lib/utils.ts) is extendTailwindMerge taught every token, so h-control vs h-7 and text-label vs text-muted-foreground merge like ordinary Tailwind pairs instead of leaving CSS order to decide. The three page-local collapsible Section copies of the workspace settings pages became components/settings/SettingsSection (same header rhythm as SettingsCard). The app's size language is four knobs, not values scattered across pages. --radius (index.css) feeds the whole rounded-lg/md/sm ladder; the spacing tokens in tailwind.config.ts (h-control 36px, h-control-sm 32px, h-control-lg 40px) are the control heights every components/ui/ primitive reads instead of a raw h-10; --chassis-header-h (52px) is the header band, consumed as h-header in TSX and as var(--chassis-header-h) by the sidebar resize handle so the two cannot drift; lib/list-styles.ts owns the row (listSurfaceClass = the tinted hairline card, listRowClass = row + padding, listRowHoverClass, listRowSelectedClass, listHeaderClass, listPillClass), lifted out of the eight files that each carried their own copy of the same string — a list page now brings only its sm:grid-cols-[…]. The header band sits flush on the rows — a wrapper space-y-* around the two is a bug (it read as the pages list having a different header than the collection list); the only gap in a list is listRowsClass, between rows. components/FilterSelect.tsx is the one list filter: its trigger always reads Dimension: value (a toolbar of bare values — "Last 28 days · All · All statuses · All languages" — says nothing about what each controls), and a filter off its default wears filterControlActiveClass, so a narrowed list can't be mistaken for an empty one; pages pair it with a Clear that appears only when something is applied. Peer of lib/filter-styles.ts (the controls above a list) and lib/topbar-styles.ts (topbarControlClass / topbarIconButtonClass / topbarIconClass — the chassis header row, where the five controls had drifted to three heights, two radii and two breakpoints; SidebarTrigger follows the same values). Retuning density is an edit to those knobs; the values there are today's look, so the layer is plumbing rather than a redesign. Gotcha the layer exists to prevent: cn() is twMerge, and a md:h-* inside a primitive beats a call site's plain h-* on desktop — desktop values belong in the base class, touch floors on max-md:. Contract in frontend/CLAUDE.md. The menu row is one size (gap-2 px-2.5 py-2 text-sm) in its four homes — ui/select.tsx SelectItem, ui/dropdown-menu.tsx DropdownMenuItem, ui/menu-item.tsx MenuItem (tile + account popover menus, panel hugs via menuContentClass, no call-site w-*), SearchSelect.tsx rows — after the tile menu carried its own size and every DropdownMenuItem call site re-applied text-xs gap-2 by hand (2026-08-29). Same rule as h-control: a raw h-7 on an Input/Button doesn't merge against the token (twMerge can't resolve custom values, CSS order wins), which is why the workspace People invite row had an input taller than its button — drop the override, ride the token.

  • lib/money.ts — THE money render: formatMoney(amount, currency) (Intl, whole units from 100 up, cents below) + useWorkspaceCurrency() (the workspace's avgLeadValueCurrency off the shared settings-row query, EUR fallback). Analytics' format.ts money() delegates here and callers pass the hook's value; the Statistics conversions card does the same. No app surface hardcodes a currency symbol — platform billing (agency seats, Luniq's own EUR pricing) is the deliberate exception.

  • Motion system — smoothness is baked into src/index.css + tailwind.config.ts: 150ms ease-out-quart transitions on every interactive element by default, the same curve as the default for all transition-* utilities, an animate-fade-in/fade-up/scale-in entrance set, and a global prefers-reduced-motion collapse. Contract in frontend/CLAUDE.md.

  • lib/money.ts — THE money render: formatMoney(amount, currency) (Intl, whole units from 100 up, cents below) + useWorkspaceCurrency() (the workspace's avgLeadValueCurrency off the shared settings-row query, EUR fallback). Analytics' format.ts money() delegates here and callers pass the hook's value; the Statistics conversions card does the same. No app surface hardcodes a currency symbol — platform billing (agency seats, Luniq's own EUR pricing) is the deliberate exception.

NavRoutePage
Orbit/Strategy — the agent home (chat); under the input sits the signals line (components/orbit/SignalsSummary.tsx), NOT per-signal previews: one slim card, width-fit-to-content and centered (capped at the composer's max-w-2xl), with a "Signals" badge (Radar icon) then a count per board status — N Open · N In progress · +6 more — each with a semantic-token status dot. Two rules keep the line short at five stages: empty statuses are omitted, and at most four segments render (MAX_SEGMENTS). Which four is decided by an INTEREST order (in review → open → in progress → approved → planned → done → dismissed: stages wanting a person beat settled ones), but they still render in BOARD order so the line reads as the pipeline; the rest collapse into one +N more carrying the summed hidden count, so the buckets still add up to exactly what the dashboard tracks. The whole card is one <Link> to /signals (a "View all ›" affordance on the right); no hover-fan, no dismiss — the dashboard is where you act. Counts come from a home-only query ([...agentStorylines.forWorkspace, "home-summary"]) that fetches storylines AND signals in parallel — signals bucket through the shared status→tab map in lib/signal-board.ts (banked topic ideas are counted on whatever stage they sit on), and storylines count as Open (statusless groupings since S6b) — every tab MUST equal the dashboard's. When every bucket is zero the slot renders NOTHING (2026-08-08) — SignalsSummary returns null on its own, so OrbitLanding mounts it unconditionally and no hasSignals branch exists. It used to fall back to six sampled starter questions; a workspace with no board got a wall of example prompts that read as the product's actual content, and an empty home screen is the honest answer until the sweep has something to say. Derives from the Signal Dashboard's storylines
Signals (topbar)/signalspages/orbit/signals/Signals.tsx — Orbit's WORKPLACE, rebuilt in S6c to the approved UI contract: a FLAT list — every finding its own row, whatever module measured it (the old board admitted only category === "topic" and hid ~90% of the store). board-item.ts is THE adapter (toBoardItem): severity/effort/stage read off the row, lens derived once (lensFor: category first, source module fallback), "At stake"/stakeFor is DELETED ENTIRELY (Leon, 2026-08-14 — the drawer, the row and the chat chip each retired it in turn; the title carries the number that matters, and byValue ranks on severity then recurrence), and the adapter's action exists IFF how.skill === "write_article" — no implicit else, so a technical card can never inherit a write action. The list's Orbit column is UNIFORM (Leon, 2026-08-11 — S7's per-card capability buttons skipped): every row says Ask Orbit — which since 2026-08-14 ATTACHES the signal (a SignalAttachment chip; the send derives its resource from the attachment) instead of quoting its title; the one autonomous action (Orbit writes it) lives in the DRAWER for content ideas only. Columns: Signal · Effort · Assignee · Stage (the stage control is the app Select wearing filterControlClass, PATCHing /signals/:id; At stake moved into the drawer header; row typography matches the Analytics breakdown tables — text-sm font-medium titles, muted text-sm cells). ASSIGNEE (backed since signal plan step 0, 2026-09-01): SignalAssignee.tsx is the whole surface — agent_signals.assignee carries a workspace member's user id and assigneeOf/useAssignee resolve it against the one people roster (hooks/use-workspace-people.ts); an id the roster no longer holds (a member who left) renders as unassigned rather than a ghost, and a card with none shows the dashed-+ affordance. THE DISC ALONE IN BOTH VIEWS (Leon, 2026-08-31, reversing the name-on-the-list call of the same day once it was on screen): initials carry it, the name lives on hover, and a first name beside every disc was a column of repeated words for a fact the disc already gave. AssigneeValue is the one place the disc and the name appear together — the detail page's Assignee row, where the page is about this one card. The disc is never colour-coded per person, and since 2026-09-01 (Leon) not for the VIEWER either: the signed-in user's own disc used to take the primary fill, which read as the one lit disc on a board of dark ones. Every person now renders on the same muted disc — colour on this board means severity, and both avatar hues and a highlighted "me" would compete with the only mark that carries urgency. Who you are is said in words, where the question is actually asked: isViewer still appends "(you)" in the assignee menu and the toolbar's Assignee filter, and sorts the viewer first in the roster. times_seen and age are OFF every surface — they feed the Most valuable sort internally (byValue); "Outstanding since" lives in the drawer. Header controls are FilterSelects: View (List/Storylines/Board) · Sort (Most valuable/Severity/Least effort) · Goal · Source. (A Funnel filter shipped 2026-08-12 and was REMOVED 2026-08-13, Leon: evidence.funnel_stage is still stamped and the agent reasons with it, but buyer stage is judgment Orbit applies when ranking opportunities, not a control an operator slices by hand — board-item.ts dropped the funnelStage field with it.) THREE ARRANGEMENTS OF ONE THING (2026-08-13, Leon): a finding is the unit of work in all three — List is flat, Board groups by stage, StorylineView.tsx groups by narrative. SignalRow.tsx was EXTRACTED for this (row + SignalRowHeader + SIGNAL_GRID + SEVERITY_PILL): the row must not fork across views, so List and Storylines render the identical component and the stage behaviour is the same everywhere. SEVERITY IS THE PILL EVERYWHERE (2026-08-31, Leon) — the row and the board card both render SEVERITY_PILL (the recipe Storylines always used), so a finding wears one severity language on every surface; the old 10px SEV_SWATCH square is gone from both, and the token moved to lib/signal-board.ts as SEVERITY_SWATCH for the two places a pill cannot fit (the dropdown option icons in SignalProperties, the dense LinkedSignals sub-list). ONE BADGE SLOT (2026-08-31, Leon): SignalBadge.tsx owns a strict priority — a measured result, else not re-checked · <day>, else New — and renders at most ONE; a row could previously carry all three at once, three marks in three visual languages beside a title that lost to its own decoration. ASK ORBIT LEFT THE ROW (2026-08-31, Leon): a button on every line is that much weight for an action taken on one of them, so the door (discussSignal) now opens only from SignalDetail and the storyline page; SignalRow no longer takes onAsk. The storyline group header IS the narrative (severity pill, headline, goal lens, N findings · M open counted over the UNFILTERED scoped set, not the tab slice); expanded it carries diagnosis + play + the two storyline-level actions (ask about the whole storyline, dismiss), then the findings under a "these are the work" label. A STORYLINE LIVES ABOVE THE STAGES (Leon, 2026-08-13): only its findings have a stage, so this view carries NO stage tabs (they belong to the List alone; the Board IS the stages) and never slices a narrative by one — every finding shows whatever stage it sits in, the row's own control moves it, and the header summarises where the whole story stands (7 findings · 4 open · 2 in progress · 1 done). Dismissed findings are the one exclusion: a human said no, so they left the story. groupByStoryline(items, storylines) is pure and exported: severity-then-size order, a storyline with nothing surviving the filters is not rendered, and unclustered findings collect in a "Not part of a storyline" group at the bottom — never forced into one. The drawer title and chat quote label read Signal, not Finding. EVERY narrative starts COLLAPSED (Leon, 2026-08-13) so the view opens as the week's shape — five headlines you can read in half a minute — rather than as a queue; an auto-opened first narrative buried the ones under it. Contrast is carried by MEANING, not decoration: the goal lens is a primary-tinted chip, the counts put their numbers in foreground weight, an expanded header takes a muted ground, and the play sits in a primary-tinted panel. The header strip carries Storylines as a stat that is a BUTTON — it opens the Storylines dashboard, and since 2026-08-24 it sits second, right of Open signals. (The view choice moved to sessionStorage and a two-value union when Storylines became its own dashboard; see the persistence note at the end of this cell.) Three honest stat cards on one row (open findings + new-since-yesterday, the Storylines door, then "Earned from finished work" = summed outcome_result deltas spanning two tracks (live since 2026-08-24 — the young-ledger gate is gone; seeded demo: fingerprints are excluded from the sum), with a plain empty state until the first real done card measures; the "Looks fixed" tile was cut 2026-08-24 — the marker lives on the rows and the detail page). Stage tabs are the workflow; BoardView.tsx renders the same five as kanban columns with HTML5 drag (a drop IS the status PATCH). Since signal plan step 0 (2026-09-01) the board is FIVE stages — the two client stages, their tab, the stage-owner badges (STAGE_OWNER deleted from board-item.ts) and the client-page bar (ShareLinkButton + open-page button) are gone with the client approval workflow; the toolbar gained an Assignee filter (All / Unassigned / each workspace person, useWorkspacePeople), and rows/cards/properties render the assignee through SignalAssignee.tsx#assigneeOf against the one roster. A ROW OPENS AS ITS OWN PAGE (2026-08-18, Leon — the drawer is GONE): SignalDetail.tsx at /signals/:signalId (route in shell/routes.tsx, same SignalBoardRoute guard; the topbar Signals link stays selected on the sub-path; the breadcrumb's last crumb is the signal title via useBreadcrumb), laid out like an issue: LEFT SignalTitle.tsx (an H1-styled auto-growing textarea, typed into directly, Enter blurs) + SignalDocument.tsx; RIGHT the actions (Write with Orbit / Ask Orbit about this), a Properties block (Stage select · Severity pill · Goal · Source · Effort · Since, then the re-measurement line), then DetailSections: Storyline (headline navigates to /signals?storyline=<id> — the Storylines view opens focused on it — plus Remove / Add-to-storyline), LinkedPages, LinkedSignals (2026-08-25 — signal.linked_signals rows with a door + a board picker over board.items; the insert-time fold entries of evidence.folded that never had a row render as door-less same rows; it REPLACED "Also reported by" and the browser-computed "More on <page>"), LinkedKeywords (2026-08-25 — signal.linked_keywords with live position / impressions / clicks read from GET /api/modules/gsc/:ws/queries?range=28d through queryKeys.gscQueries.forRange, restricted to the linked pages when attributed and marked "· site" otherwise, the window named in the header; it REPLACED the read-only SignalReadings, deleted), LinkedPrompts (2026-08-28 — signal.linked_prompts, built exactly like LinkedKeywords: rows with the latest scan's "named on N of M" read live from GET /api/modules/ai-visibility/:ws/overview through queryKeys.aiVisibility.overview, a SearchSelect over the workspace's active tracked questions plus a typed question tracked as-is; it REPLACED the read-only SignalPrompts over evidence.prompts, deleted), Results (measured on a Done card; an open card's section says when it will be), WorkTrail, How it closed (closed_by attributed). Card links (2026-08-28): a link in a document or storyline narrative that points at /w/<ws>/signals/<id> (the backend's card-refs.js writes those when Orbit cites another card) resolves through signals/card-links.ts#cardLinkFor — title from the board cache, opens in place — via RichTextEditor.internalLink in SignalDocument and StorylineDocument; the link tooltip leads with the name (an in-app button) instead of the raw href. Storyline links (/signals/storylines/<id>) resolve the same way (headline from the storylines cache). Link popup (2026-08-28): the editor's two prompt() calls are gone; components/LinkPicker.tsx rides SearchSelect (the "Add a page" recipe, anchored at a zero-size fixed trigger under the selection): ONE search box over every LinkSource the caller passes (RichTextEditor.linkSources, searched together, debounced), a typed URL or bare domain offered as the leading "Link this URL" row, Enter picks the first row (SearchSelect.onEnter). A kind filter (All · pages · signals · storylines) sits above the search field (SearchSelect.header) and narrows which sources are searched. signals/link-sources.ts#useSignalLinkSources gives both documents three sources, 8 rows each — pages (the Pages picker endpoint, server search), signals and storylines (the board cache, own card excluded; the sub line names the kind) — writing the same /w/<ws>/signals/… hrefs card-refs.js writes, so cardLinkFor opens them in place. A collapsed selection inserts the target's title as the link text; editing an autolinked URL swaps its text for the picked title. SignalProperties gets a Re-checked row (SignalDetail.tsx#verifiedRow) from evidence.verified when a lens last confirmed the card with its numbers. DetailSection is the one contract for those blocks (2026-08-25, Leon: "show all, even with empty states"): always rendered, count in the label (the owner tag — Orbit + you / Orbit / Measured — was cut 2026-08-28, Leon: the empty-state line and the add affordance already say who fills a list), empty + whyEmpty render a one-line reason instead of a blank (since 2026-08-28 the three linked lists pass whyEmpty only when locked — an editable empty list shows just its picker, the StorylinePicker pattern), and every section folds on its heading with the state remembered per section in localStorage (signal-section:<label>). ONE section is open on a first visit: Properties (2026-08-28, Leon) — the fold default is opt-in (defaultOpen, passed by SignalProperties and nothing else) rather than "open unless empty", so the column reads as a list of what the card carries and you open the one you want; an empty section never starts open, and a remembered state always wins (a person who already visited keeps their own folds). Data + actions come from use-signal-board.ts (useSignalBoard(workspaceId) — the signals/storylines/collections queries, moveSignal, the storyline door mutations, writeIt, discussSignal/discussStoryline), shared with Signals.tsx so nothing forks between list and page. writeIt OPENS the approval popup since runs-and-actions PR C (2026-09-02): the click never queues — writeDraft holds the card under review, SignalDetail renders pages/orbit/content/WriteArticleDialog.tsx (THE one write form, extracted from CollectionView's inline dialog) prefilled from the card's how.params (+ customer_question passed through extraParams), and onWriteQueued claims the card to In progress after the dialog's confirm queues. The dialog owns field state, the reference-pages picker, keystroke truncation at the backend-declared caps, and the gate: BriefGate.tsx#fetchBriefPreview → the generic POST /api/agent/:ws/skills/write_article/preflight (full-schema validation + directives + declared origin: the manual New button says manual, the card says signal), then the queue with the gated brief and the signal_id stamp. CollectionView keeps only newArticleOpen + onQueued={() => setJustQueued(true)} — ~200 lines of inline dialog left it. ONE keyword surface (pre-writing, simplified 2026-09-03 — Leon: "it shows both"): on a multilingual workspace the target-keyword FIELD becomes the section — one editable row per configured language, rendered in the field's own position; the anchor row (the demand's language, else the primary) IS the target keyword, no separate field, no read-only mirror. Languages ride the archetypes response (GET /api/collections/:ws/archetypes now returns languages from settings — the hook the dialog's callers already share). The preflight's locale_plan fills EMPTY rows only (a typed row is the person's), with source hints (measured + volume / suggested / researching…). ONE brief build per approval, cached by the composed params: prefilled doors preview on open; the manual door's first confirm fills the rows, the second queues — nothing queues before the researched rows were on screen for exactly these fields, and a changed anchor field re-previews. Non-anchor rows ride params.locale_keywords (verbatim at write time); single-language workspaces keep exactly the one plain field. Chat closes the loop (PR D): a chat-proposed write_article streams as a pending_approval card whose "Review and approve" opens the SAME dialog (submitOverride mode: the confirm approves the pending job with the edited params via onReviewJob(jobId, "approve", params) instead of queueing a new one; button reads "Approve and write"). The proposal's params ride the history hydrate and the job-updates poll only while pending — the live SSE never carries them, so the button spins for the seconds until the first poll lands; the page finds its row in the same React Query cache (no second fetch); a gone id renders "This signal isn't on the board". signal-save.ts is THE copy write: useSaveSignalCopy PATCHes /signals/:id/body with { body_md?, title? } (the route accepts either; saveSignalBody claims on the first save whichever field moved) and usePatchSignalCache patches the row in place; saving is SILENT (no Saving/Unsaved/ownership line — only a failure speaks). board-item.ts: a topic card reads as how.params.title only while unclaimed (!body_edited_at); a claimed card carries the row's title (the field the page edits). SignalProposal.tsx renders Orbit's proposal as WHAT CHANGED — splitSections on ## headings, changed sections rendered (react-markdown), unchanged collapsed to "heading · unchanged", a changed title on its own line — before Take/Keep. Ask Orbit ALWAYS opens a NEW chat (OrbitPanel.tsx: an attachment id change nulls the thread after the resume effect). A person's own signal + Linear-style property editing (2026-08-19): SignalNew.tsx at /signals/new (the New signal button beside the toolbar filters; static route declared before signals/:signalId) renders the SAME page as a draft — SignalPageFrame (extracted into DetailSection.tsx) + SignalTitle (now controlled/presentational; the detail page's TitleEditor wraps it with the silent debounce) + a RichTextEditor seeded with Orbit's three headings + SignalProperties + StorylinePicker + AffectedPages bound to local state, and Create signal POSTs /signals, drops the row into the board cache and navigates (replace) to /signals/<id>. PropertyMenu.tsx is THE read-at-rest / hover / click-to-change recipe (DropdownMenu; locked = no door): SignalProperties.tsx (Stage · Severity · Goal · Effort, then read-only Source · Since at the bottom — the Stage row moves through board.moveSignal, every other row through useSaveSignal), components/SearchSelect.tsx (promoted out of signals/ 2026-08-20) = THE app-wide searchable-picker recipe (the DropdownMenu surface — p-1, rounded-sm rows, check mark — with a quiet search row on top and NO rule under it; 2026-08-19) used by PagePicker.tsx, StorylinePicker.tsx, the shared CountryMultiSelect / CurrencySelect / TimezoneCombobox pickers, and the new-item dialog's Reference pages picker (CollectionView.tsx, 2026-09-01, Leon: "not our usual add a page popup") — which had hand-rolled the same thing against the SAME fetchSitePagePicker endpoint the signal page's Add a page uses: its own Popover, a bordered search Input with a rule under it, rows carrying a page_type tag that never decided a pick, so picking a page looked like two features depending on where you did it. Its arrival is why the option list now carries overscroll-contain + a stopped wheel: this is the first caller that opens OVER another scrollable surface (the dialog scrolls its own body), and without it the wheel reached that body and scrolled the form behind the popover while the options sat still. pathOf moved from signals/linked-pages.ts to lib/site-pages.ts beside that fetch (re-exported, so the signal call sites are untouched) rather than have a content page import a helper out of the signals folder — which is built EXACTLY like Linked pages (2026-08-19): the linked storyline is a row (full headline, click opens its page, ✕ on hover unlinks), and only when none is linked an "Add to a storyline" SearchSelect trigger (one storyline per signal, so row and picker never coexist; the detail page maps a change onto remove/add members). signal-save.ts is now useSaveSignalPATCH /signals/:id with any of { title, body_md, severity, effort, goal_lens, added_pages }, OPTIMISTIC (cache patched in onMutate; a failed save refetches + toasts). LinkedPages.tsx (was AffectedPages; linked-pages.ts was affected-pages.ts and is now READ-ONLY formatting — linkedPagesOf, metricOf, pathOf, noPagesReason; the derivation moved to the backend) renders signal.linked_pages — ONE list, Orbit's and the person's: every row removable and PagePicker.tsx (SearchSelect + server-side search over fetchSitePagePicker, hands back url/title/resource; a typed http(s) URL is offered as "Add this URL") when onChange is given — no Copy-all, no count in the section title (Leon, 2026-08-19) (the whole list goes back through useSaveSignal { linked_pages }), the health lazy fetch is GONE (the detector stores its occurrences on the row), "Open in Orbit" resolves every URL through useResolvedPages (host-guarded) or the stamped resource, and a capped list says "N of total" (+ a Site health link on health cards); a row is the page's label ONLY — the numbers, status and linked-from moved into the document as facts (Leon, 2026-08-19: "dead simple"; metricOf deleted, the numbers stay stored for the agent); board-item.ts: lensFor prefers goal_lens, sourceLabel('manual') = Manual (also a Source filter option), EFFORT_LABEL exported. A closed card's properties render locked (the stage stays movable). Storylines are their OWN DASHBOARD (2026-08-19, Leon: "like Linear has issues and projects — projects are not shown on issue boards"): Storylines.tsx at /storylines (reached from the board's Storylines stat card — NO topbar link for now (Leon, 2026-08-19); the Signals topbar link stays lit on /storylines; same SignalBoardRoute guard; one listRowClass row per storyline — pill · headline · Yours · goal · signals/open/in progress/done — sort severity/size/recent, goal filter, New storyline = the inline StorylineForm; a synthesis storyline with no surviving signals is dropped, a person's always shows; dismissed signals don't count) and StorylineDetail.tsx at /storylines/:storylineId (the one-day-old /signals/storylines[/:id] addresses redirect; ?storyline=<id> on Signals redirects to the page). Signals.tsx is SIGNALS ONLY: View = List/Board (SignalView union shrank), a Storyline FilterSelect (all / not in a storyline / each storyline) slices the rows like an issue list slices by project, the Storylines stat card is THE door to /storylines; the "Showing all N — nothing is capped" footer is gone (the footer speaks only when the server window cut a read), as is the Storylines footer line; StorylineView.tsx is DELETED (isYours moved to board-item.ts); the storyline chat-scope cleanup moved into useStorylineScopeCleanup (use-signal-board.ts), called by the storyline page. The storyline page itself — SignalPageFrame; LEFT the headline (SignalTitle + a ≥10-char debounced save), StorylineDocument (now silent, useSaveStoryline, and it SEEDS from diagnosis + play with the backend's own headings when body_md is empty, so every storyline is editable), and FULL WIDTH under both columns (SignalPageFrame below) The signals — member SignalRows (open/ask/move) + an AddSignal SearchSelect over the board's other signals (→ addToStoryline); RIGHT Ask Orbit about this storyline, SignalProperties without a Stage row (status is optional now; extraRows carries Impact; Source reads You/Orbit via isYours), WorkTrail storylineId; the two exits (Disband / Dismiss storyline, then back to the Storylines view) live in a ⋯ DropdownMenu beside the Ask Orbit button (Leon, 2026-08-19 — no Exits section). storyline-save.ts = useSaveStoryline (optimistic PATCH /storylines/:id for headline/body_md/severity/goal_lens/effort). StorylineForm is create-only; editStoryline left useSignalBoard. BoardView (2026-08-18) speaks the list's language: THE TROUGH IS GONE AND THE CARD IS THE ROW (2026-08-31, Leon): a card used to be the list row's translucent bg-muted/40 sitting on the column's bg-muted/25 trough — two washes stacked, which is what made the board read flat. The fix was the TROUGH, not the card: with the column ground removed, listRowClass reads fine on the page, so a card is once again EXACTLY a list row (listRowClass + listRowHoverClass — same surface, border and hover, stacked with listRowsClass) and the column carries no padding, because there is nothing to inset from. Density and edge therefore move for the list and the board together, from lib/list-styles.ts. A card is severity pill · title (3-line clamp) · a footer of assignee, effort and the one badge — the source line and the severity word beside the pill are gone, both having said what something else already said. The column header is plain (stage · owner badge · count) at px-3, the row's own padding, so labels sit on the same optical line as the titles beneath them. A DROP SHOWS WHERE, NOT WHICH (2026-08-31, Leon): dragging no longer tints anything — the board opens an OUTLINED SLOT at the exact index the card will take, derived from sortFn (dropIndex) so the preview cannot lie, sized to the dragged card's measured height (dragHeight, captured on dragstart). Three rules keep it from flickering, each a bug that was hit: there is NO onDragLeave (it fires whenever the cursor crosses into a child card, and opening a slot reflows the cards under the cursor, which fires more — a feedback loop), so onDragOver claims the column and onDragEnd alone clears; every OTHER card plus the hover-only hide button take pointer-events-none mid-drag so the column owns the drop, while the SOURCE card keeps its own or it never receives dragend; and the source card fades to opacity-40 but MUST stay in the flow — hidden/display:none on the drag source cancels the drag outright and the card cannot be dropped at all. EMPTY STAGES HIDE (2026-08-19, Leon) into a Hidden column on the far right (rendered only when something is hidden), BUILT LIKE EVERY OTHER COLUMN since 2026-08-31 — same 300px width, header band, transparent ground and listRowClass rows, so the board has one column language instead of a tinted special case: each row names the stage + owner + count, click reveals it for the visit (revealed set), and the row is a DROP TARGET so a card can still be moved into a hidden stage. Legacy detail of the drawer era, kept for the design decisions it records: MINIMAL BUT COMPLETE (Leon, 2026-08-14) — two deliberate cuts. The raw evidence key/value dump is GONE: its filter kept scalars and dropped objects, so it printed the detector's own dials (settle_days, surfaces_checked, site_level, band) and hid the pages, which are object-shaped; the qualifying number now rides the header line next to severity/lens/source, and problem age is demoted to a footer line. And the storyline's diagnosis/play/actions moved OUT — a storyline is a grouping, not a work item, so the drawer names it and onViewStoryline flips to the Storylines view with focusId expanding + scrolling that narrative (an effect, not a useState initialiser, because the jump can happen while the view is already mounted). affected-pages.ts is THE extractor — pure, and SHAPE-DRIVEN NEVER KEY-DRIVEN, because the same evidence key means different things per producer: pages is an array of {url,position,impressions} on gsc but a NUMBER on autopilot; examples is [{url,title}] on collections/autopilot, [{query,position}] on gsc (queries, not pages) and [{title,sessions}] on pixel. So an entry counts only if it carries an http(s) URL under url/path/page, which makes a query list correctly yield nothing instead of garbage. AffectedPages.tsx renders it: the live URL, the metric that qualified the page, a second door into the Orbit view where a resource resolves, capped at 6 with +N more, plus Copy all (the real workflow is pasting into a sheet, a ticket or a client mail). Health is the deliberate exception — its cards carry only check_id + a count, so URLs are fetched lazily from the existing GET /api/modules/health/:ws/issues/:checkId via queryKeys.health.issues, the same route IssueList uses; at 84 of 432 live cards it is the largest single class and leaving it blank was not an option. Measured on live data: 220/432 resolve straight from evidence, +84 through the health fetch — whose occurrences keep their evidence since 2026-08-19: HTTP <status> as the row's metric and linkedFrom referrers as a per-row "linked from" link list (where a broken link is fixed) — and the remainder are page-less BY DESIGN (ai-prompt, demand, backlinks, site-wide) with noPagesReason naming which. DetailSection.tsx (was DrawerSection.tsx) holds the label recipe so the page's column and the pages panel cannot drift apart. THE DOCUMENT (2026-08-14, Leon). The drawer's prose is no longer a rendered detail paragraph but SignalDocument.tsx — the card's body_md in RichTextEditor, straight editable: no click-to-edit, no hover state, no raw/rendered toggle. NO new editor was introduced — RichTextEditor already IS the markdown round trip (formatContent in, htmlToMarkdown out) and MarkdownFileEditor had already proved this wiring, including its one trap: the editor fires onContentChange once while MOUNTING to seed word count, and passing that through would autosave on open and mark every document human-owned the instant it was looked at, permanently killing regeneration. The mount flag is per-CARD here, not per-component-lifetime, because the editor is uncontrolled and seeds only on mount — so it is keyed by signal id, and the flag resets with the remount. Saves are debounced (800ms) to PATCH /signals/:id/body and invalidate the board query, since the list carries body_md. Plain bullets, not - [ ] task items: RichTextEditor has no task-list support and a checkbox round-trips into literal "[ ] " text in front of every step. The split to keep: markdown carries judgment (what's happening / proposed plan / notes), structured columns carry measurement (affected pages, outcome series, work trail) — the panels render BESIDE the document and never inside it, because an edited section is frozen and any number baked into it would freeze with it. Ownership is per SECTION (see the agent guidemergeSignalDocument): what you rewrite stays, what you left alone keeps refreshing, and the status line under the editor says which of the two you are looking at. The drawer header carries the signal title, then severity + goal + source and NOTHING else — the "at stake" figure was removed (Leon, 2026-08-14) because it repeated the title's own number on nearly every card; stakeFor stays in board-item.ts only because the Most-valuable sort ranks on whether a card HAS a figure. The drawer itself widened 26rem → 30rem (DetailPanelContext) for the same reason — prose at 26rem wrapped every other line; it is the app-wide detail panel, so every page's drawer widened with it. Deep link ?storyline=<id> opens the Storylines view focused on that narrative (was: the first member's drawer). The footer is honest: "Showing all N — nothing is capped", or names the server window when a 1000-row read truncated. The plan drag-order engine was NOT rebuilt (the approved artifact has no manual plan order — §10 Q5's recommendation embodied; the backend position machinery is untouched). The right column is GONE (goals retired entirely — Leon 2026-08-11, plan step S13): the board is the full-width flat list the approved artifact drew; production + review live on Content. Sort persists in localStorage (signals-sort — how you work). The VIEW is remembered too since 2026-08-31 (Leon), in sessionStorage (signals-view) exactly like the tab — this REVERSES "the view always opens on List" (2026-08-18), because opening a card and coming back threw you off the board every visit, the same complaint that pinned the tab; a new browser session still opens on the list, and neither view can hide a finding, so remembering one is safe in a way a remembered filter is not. The goal/source filters deliberately reset each visit (a remembered filter silently hides findings). ItemCard.tsx is deleted; the severity pill/lens labels live in board-item.ts.
Content/content, /content/c/:collectionKeyCollectionView — one collection's article list
/content/pagesSitePages list
/content/site-pages/:sitePageIdSitePageDetail
/content/:idBlogPostDetailV2 — article editor. A 404 from the article fetch redirects to /content/site-pages/:id (2026-08-17): the site's pre-existing posts are site_pages (page_type 'blog'), so an id linked under the article route is often a page — the page detail 404s on its own if neither exists. The agent's LINKS fragment routes by entity_type, and this catches the rest.
Social / Sales"Coming soon"
Performance/performance/statisticsStatistics (Orbit Pixel)
/performance/search-consoleGoogle Search Console dashboard
/performance/backlinks · /health · /ai-visibilityBacklinks / Health / AI Visibility
Market/marketpages/orbit/market/ — Scorecard / Keywords / Their moves (pure data + viz; curation is the only write)
Settings/settings/*Hub + 6 settings pages

Non-workspace routes: /login, /register, /forgot-password, /reset-password, /home (the empty-dashboard landing, in PersonalLayout), /agency-setup (the agency wizard), /workspace-setup/new (create a workspace: name + URL) → /workspace-setup (the workspace wizard — renamed from the blog-era /workspace-setup/blogs, which now 302s; the wizard renders on CenteredPage width="md", same chassis as every entry screen, band logo = back-to-workspace), /join/:token, /join-agency/:token, /docs, /audit/:auditId (public hosted lead-audit report — pages/audit/, capability URL, no session; data from the backend's public /lead-audit/:auditId/report.json), /s/:token (branded shared pages — see below).

The four auth pages (/login, /register, /forgot-password, /reset-password) render in components/CenteredPage.tsx — the entry-screen chassis: the app's own frame (sidebar-surface ground, h-header band, the rounded bg-background border-border/60 page card) with one centered max-w-xs column inside the card and the outline mark bleeding off the card's corner, so signing in already looks like being in. Slots (band, bandRight, width) let later consumers (invite acceptance, the agency wizard, /home) swap the band's left side for an agency logo or tabs — no variants. /register collects only first/last/email/password (agency details moved to the agency-setup flow; ENTRY_ONBOARDING plan) and shows a "Check your email" state with resend for Supabase confirmation; the confirm link lands on /email-confirmed (pages/EmailConfirmed.tsx — a plain "Email confirmed" + Continue; the hash tokens have already signed the user in by then) and Continue hands off to routing.

pages/Join.tsx is THE invitation page — one component serving /join/:token AND /join-agency/:token (email-compat alias; the KIND comes from the token via GET /api/invite/resolve/:token, not the path). It renders on CenteredPage wearing the sending agency's brand: their wordmark in the band, their mark above the heading (both through BrandLogoArt, so dark-mode treatment is identical to the chassis), their colour on the primary button (BrandProviderbg-brand), and "{inviter} invited you to join/collaborate on {name}" as the heading. Sign-in vs sign-up mode via check-email; signup goes through POST /api/invite/signup (born email-confirmed); accepting is implicit in signing in — guests land in /w/{id}, agency staff at /. All invite fetch helpers live in lib/invite.ts (one API_BASE — the old per-page bases at :3000/:4000 are gone, and the deprecated /api/accounts alias with them). AuthLayout (the two-column brand-pitch shell) was deleted 2026-08-11 — McpAuthorize moved onto CenteredPage with everything else; only Maintenance keeps its own hand-rolled layout (with BrandPitch), on purpose.

pages/AgencySetup.tsx — the agency onboarding wizard at /agency-setup, 4 steps on CenteredPage with the workspace wizard's step language (label + hairline bar): Your agency (creates the agency — ACTIVE from the start, there is no approval step since 2026-08-27 — via POST /api/agency/register and invalidates queryKeys.agency.forUser; carries the Billing currency select — USD/EUR/GBP, pre-selected by lib/billing-currency.ts from the browser timezone the way the workspace timezone is (Europe/London → GBP, Europe/* → EUR, else USD), editable until a subscription exists) → Your brand (skippable) → Your team (skippable; role picker per row) → Your plan — the ONE gate, branched on useAgency().billingEnabled and agency.plan.ok: billing on and no plan in force → the plan card (agency plan line + workspace QuantityStepper + monthly total from agency.pricingPOST /api/agency/billing/checkout → Stripe Checkout), returning to ?checkout=success which runs POST /billing/sync and then finishes; a plan already assigned by Luniq, or billing off → a "Your plan is ready / You're set" card with one Go to your agency button. Finishing = POST /api/agency/complete-setup (stamps onboarding_completed_at, operator heads-up; nothing waits on it) → /home → the hub. Routing: /home redirects users with no agency (and admins with an unfinished wizard, i.e. agency.onboardingCompletedAt === null) into /agency-setup; a finished agency is bounced back out (except mid-Checkout-return); ?step=1..4 re-opens a step (the Checkout cancel URL uses it); PATCH /api/agency accepts {name, note, currency}. (Onboarding.tsx was deleted 2026-08-10 — its three jobs moved to wizard step 1, /home, and /workspace-setup/new; the old /onboarding URL 302s to /home.) The brand step reuses the branding editors verbatim — components/agency/BrandEditors.tsx (useBrandingEditor + LogoEditor + ColourEditor), extracted from AgencyBranding so the wizard and the settings page can never drift. The "Powered by" toggle and the shared-page footer badge are gone product-wide (2026-08-10): showPoweredBy left WorkspaceBranding, AgencyBranding, and PublicShell — an agency's surfaces carry only the agency's brand.

Major surfaces

Orbit home + docked panel

The workspace root IS the agent chat (AgentChat variant="home"): greeting, one composer, the signals line below it (nothing there until the board has something — see the routes table), menu (Pause/Resume agent — the workspace's ONE off-switch: every scheduled job for the workspace stops, chat always works; the state is READ from the workspace context (useWorkspaceAutomationautomation.paused, the same object the status pill renders) and the toggle POSTs then invalidates the context + the statuses map, so composer, sidebar, switcher and agency list flip together — the old GET-on-mount inside the chat was the third reader of a one-definition bit and is gone with its route; while paused the composer carries a small Resume banner — the ONLY thing that banner is for; "setup incomplete" is said once, by the chassis' WorkspaceReadinessBanner, which renders the server's setup list from the workspace context and never recomputes the rule; Recent chats). Streaming chat renders markdown text, a collapsible activity header (tool calls interleaved with the model's working narration — text streamed before a later tool call; only text after the last tool call renders as the reply), skill cards (queued/running/done/failed) that update live in the open thread — while any visible card is still queued/running, the chat polls GET /api/agent/:id/chat/job-updates (watched ids only, 4s, self-stopping) and merges statuses in place, appending each ✅/⚠️ outcome as it lands — plus skipped-action warnings, file cards (see the file drawer below), and sandboxed HTML artifacts. Edit with Orbit (select-to-chat): selecting text in the article editor offers an Orbit action — in the rich-text body it joins the existing selection toolbar (RichTextEditor onAskOrbit), on plain fields (title/intro/meta/custom) a floating chip appears near the selection (SelectionOrbitButton, field label read from the closest [data-orbit-field]). Both call askOrbit(text, label) on the panel context: the panel opens on Chat with the selection as a quote chip above the composer (✕ clears; composer placeholder switches to "What should Orbit change about this text?"); on send the quoted text is prepended verbatim to the message (Selected text from the <label>: """…"""), so the agent gets the exact passage (reusable as a body_patch.find) plus the article from the page resource; the user bubble parses that wire format back (live and after reload) into a compact "Selected text · <field>" quote card above the instruction — the raw block is never shown. components/orbit/ComposerAttachment.tsx is THE chip (extracted from AgentChat 2026-08-14 when signals became attachable): one frame, two variants — quote (text, travels INSIDE the message body) and signal (a board card: severity dot from the one SEVERITY_PILL recipe, now in lib/signal-board.ts so a component never imports a page module and the share page's fourth drifting copy could be folded in; title clamped to two lines — NO meta line: it shipped with {lens} · {where} · {stake} under the title and Leon cut it same-day, the title already carries the number that matters). It is a SIGNAL, never a "finding" (Leon) — one object, one word, in the type name (SignalAttachment), the persisted kind, the resource label and the copy. A signal does the OPPOSITE of a quote: nothing is stuffed into the message textAgentChat.sendMessage derives the send's resource: {type:"signal", id} FROM the attachment (never from the page scope, so it survives navigating between attach and send, and discussSignal sets no panel resource at all — only storylines still use the page-scoped resource), the backend loads the whole card as CURRENT FOCUS on that one send only (the attachment clears after it, so the focus template is structurally unable to re-fire on follow-ups — the failure mode of the 2a45590a test thread, where every reply re-ran the four beats over the user's actual question), plus a display-only attachment persisted on the user's log row and replayed by the history route, rendered read-only (no ✕) inside the user bubble. Cleared on send and on panel close. ASK ORBIT ATTACHES CONTEXT AND WRITES NO QUESTION (Leon, 2026-08-14): the prefilled prompt is GONE from both doors (setPrefill(null)), so the composer opens empty. The old one ("Walk me through this: what's happening… If we agree on a step you can run, run it") asked for an essay and got one — ~2000 chars a reply — and promised execution the button never offered; replacing it with a better-worded prompt was still putting words in the user's mouth, so there is none. When a watched edit_article job flips to done, the poll additionally broadcasts a orbit:article-updated window event ({ articleId, fields } — the endpoint returns the job's articleId for this); BlogPostDetailV2 listens, and if it's the open article it refetches and re-seeds the editor in place (the editor key carries an agent-edit version) so the approved change lands in front of the user, with a one-shot .agent-edit-flash accent wash on the changed field widgets (keys mapped to widget roles via the collection field map). Scroll follows the stream but never fights the user (AgentChat, shared verbatim by the docs assistant): the view snaps to the bottom in a useLayoutEffect (before paint, so a followed stream never paints low and jumps), and detaches the instant the user pulls up. wheel/touchmove are the reliable detach signals — they fire SYNCHRONOUSLY, before the next token's snap, so the flag flips and the snap no-ops; scroll events coalesce to a frame, so they only ever RE-STICK (back within 40px of the bottom → follow again) or detach on an actual upward delta (scrollbar drag, PageUp — no wheel event). Belt-and-braces: each snap records its own scrollTop, and a snap that finds the view ABOVE that mark treats it as a user move and detaches — catching upward moves whose event lands in the same frame as the snap. Sending, clearing, or switching thread re-attaches. Getting this wrong reads as the chat yanking you back down mid-generation. A wait state is ALWAYS visible while the model streams: the cycling thinking line before the first chunk, the activity group's spinner during/after tool calls, the caret on growing text, the thinking line returning at the tail when the last chunk is a card (skill/artifact/file) — and from the plan_start SSE event a "Preparing the result…" line (600ms-delayed so quick turns never flash it) that holds until the first queued-action card, error, or done lands, covering the whole plan-generation + server-apply window that previously read as stalled. The Ask Orbit button (OrbitTrigger) wears the sidebar-accent selected pill while the panel is open (mirroring the topbar Signals button) and opens the right-docked OrbitPanel on any other page with a Chat/Activity toggle (always landing on Chat — unread notifications show as a dot on the Activity tab, never as the landing view) — and the conversation is ONE workspace-wide store that travels with the user (Leon, 2026-08-14). Threads used to be scoped to where they originated (the panel listed only the current scope's threads, remounted per scope, and resumed per scope — so navigating killed the open chat and a conversation had elsewhere read as lost); now AgentChat is keyed by workspace only, the open conversation survives every route change, the panel resumes the newest conversation once per open (resumedRef) and never re-resolves on navigation, and the agent knows where the user is because every send carries the CURRENT page descriptor + the current page's declared entity (useOrbitContext) — the thread remembers its origin, the message carries the present. useAgentThreads(workspaceId) is the single loader for both surfaces (react-query, queryKeys.agentThreads.all — the forScope key and the route's ?resource_type/?page_path filters died with the scoping); every list shows every conversation, each row carrying an origin BADGE (threadScopeLabel — "Article", "Search Console"), and picking one opens it right where you are — home included, which no longer navigates to the origin page (the openThread/pendingThreadId pinning machinery in OrbitPanelContext was deleted with that flow). Activity = in-flight skill jobs (4s poll) + the workspace notification feed, rendered as a quiet timeline: rows group under day headers (Today / Yesterday / date; feed actions ride the first header line) and each is one line — type icon + short action label (derived client-side from type + metadata.skill — the stored title/body stay verbose for the emails and are never shown, except failure titles and as a fallback for unlabeled types) + the entity name pulled from the quoted part of the stored title, truncated; short relative time on today's rows only, no bodies, no badges. Every row is clickable to its relevant spot (resolveNotificationPath in NotificationList covers every fired type: article skills → the article detail, the weekly Signals overview → /signals, AI-visibility → its dashboard, unknown → home) — except destination-less legacy rows (the retired agent_weekly_digest, path null), which open a small reader dialog showing the stored body (the digest recap IS the body) with an "Ask Orbit about this" button that closes the panel and navigates to the HOME chat carrying both the prompt (orbitPrefill) and the notification's stored body as a quote (orbitQuote) in router state — Strategy.tsx passes the prompt into the home composer via AgentChat's prefill and the body via quote (captured into local state so the prefill-consume strip doesn't drop it before send), so the agent answers grounded in the actual content (it has no access to the notifications table and would otherwise reconstruct the digest from the work ledger) rather than a title alone; the state is stripped on consume (replace navigation) so reload/back never re-fills. Composer pre-fill: the panel context also carries prefill — dashboard actions (Signals' "Work on it with Orbit" / "Discuss first") set it before opening the panel; OrbitPanel hands it to AgentChat, which fills + focuses the composer once (prefillAndFocus) and clears it (also cleared when the panel closes). Nothing is ever auto-sent.

hooks/use-running-jobs.ts — ONE polled cache for "is Orbit working?" (2026-09-02). useRunningJobs(workspaceId) wraps GET /api/agent/:id/activity in React Query (queryKeys.agentRunningJobs, 4s refetchInterval; loaded gates empty states so the Activity view never flashes "Nothing yet" during the first fetch). Since runs-and-actions PR B it returns BOTH halves: actions (queued + running skill_jobs) and runs (open routine pipelines, backend-labeled) — the Activity view renders them as Running (actions, "Working on an article") and Routine (runs, label + "running for Nm"), and the header Activity button spins for either. Two readers, one fetch (React Query dedupes): the panel's Activity view lists the jobs under Running, and the header's Activity button wears the SAME spinner (Loader2 animate-spin text-[#97d6df]) in place of its icon whenever jobs are in flight — background work is visible with the panel closed, and the two surfaces can never disagree. Its tooltip reads "Orbit is working (N)" only while its view is closed; showing it, the button's job is Close and the label says so. This replaced the Activity view's own setInterval + authFetch poll, which ran only while the view was mounted. OrbitTrigger renders TWO buttons since 2026-09-02 (Leon): Ask Orbit (Sparkles → the chat view) and Activity (→ the activity view) are separate header controls — one button hid the Activity tab behind a landing rule nobody could predict. Each button toggles ITS view (click closes only when the panel already shows that view, else opens or switches); the unread-notification dot and the running spinner both live on the Activity button, and on the agent home — where the page IS the chat — only Activity renders.

components/orbit/MicButton.tsx + hooks/use-dictation.ts — dictation in the composer. One hook instance lives in AgentChat and is passed to OrbitLanding, so both composers share it (only one is ever mounted); it records with MediaRecorder, POSTs to /api/voice/:ws/transcribe via authFetch (which sets ONLY Authorization, so the browser still writes the multipart boundary — never add a Content-Type here) and appends the transcript to whatever is in the box, so you can type half a thought and say the rest. Recording caps at 2 minutes; Esc discards the take and never spends anything on it, while the button stops-and-transcribes — the difference between "never mind" and "done". The level meter is deliberately NOT React state: an AnalyserNode is sampled on demand through getLevel() and MicButton paints the bars from its own rAF loop, writing transform straight onto the elements — driving it with setState would re-render a 2k-line chat sixty times a second while someone talks into it. Bars carry a 0.18 resting floor so silence reads as "hearing you" rather than as a broken mic, and the ease is asymmetric (fast attack, slow release) so it settles between words instead of strobing. The hook returns supported: false on a browser without MediaRecorder/getUserMedia and MicButton renders nothing — no broken affordance. Permission and cap errors surface in a small dismissible card above the button, inside the voice component, so the composer's own markup stays untouched.

components/OutlineMark.tsx — the brand mark as a hairline outline, THE one copy of the SVG path. It is inline SVG rather than an <img> of public/embed/icon-outline.svg (the path is that file verbatim, in a viewBox padded by 5 units so the flush export's stroke isn't sliced at the edges) for two things an image can't do: the stroke is currentColor, so ONE layer reads as a white outline in dark and a faint graphite one in light, and vector-effect="non-scaling-stroke" pins the line to 2 device px at any scale — that's what keeps it a hairline instead of a fat band. The diagonal gradient mask (transparent top-left where a page's column sits, peak out in the corner) is baked in; callers own size/position/opacity. Two positioners: components/orbit/OrbitBackdrop.tsx on the Orbit page (Strategy.tsx renders it behind a relative z-10 wrapper, since the backdrop is absolute inset-0; size clamped — a plain h-160% balloons onto the composer on a tall window and shrinks to a sticker on a short one; hidden below md; peak opacity 12% light / 14% dark — light needs more, a graphite hairline on white carries less than a white one on near-black; depends on Orbit's pageShell dropping the card's inner padding — restore that padding and the mark floats instead of bleeding) and CenteredPage on every entry screen (same corner, same clamp idea, so entering the app feels continuous with being in it).

In-page detail drawer

contexts/DetailPanelContext.tsx is THE pattern for row-detail sidebars, and any future in-page sidebar rides it (useDetailPanel().show({id, title, content}) — first consumer: Statistics → Journeys). A solid panel — full white in light mode, full black in dark — slides in over the page card's right edge (drawer-in/out keyframes in index.css), flush with the card's top/bottom/right (1px inset keeps the card's outline) and dissolving into the page through a 4rem left-edge fade instead of a border, shadow, or darkened backdrop. Inside it the detail keeps the card design (rounded + bordered bg-background), with a header in the Orbit panel's design language. Deliberately non-modal: no scrim, no click-catcher — the page underneath stays scrollable and clickable, so walking a list with the detail swapping in place is the workflow; X, Escape, re-clicking the open row, or a route change closes it. One panel at a time: opening it closes the docked Orbit panel and vice-versa (the yielder handoff in OrbitPanelContext, both animations on the one PANEL_CLOSE_MS). AppLayout mounts the provider as the page card's positioning context, so the page never resizes and links rendered in the drawer resolve OUTSIDE the page's route context — use absolute /w/:workspaceId/... paths there, never relative ones.

Agent file drawer

contexts/AgentFilePanelContext.tsx — a file the agent made in chat (create_file) opens as a document instead of staying a download. It is the detail-drawer pattern above (solid ground, drawer-in/out, left-edge fade, non-modal, X/Escape/route-change closes, one at a time) with ONE deliberate difference, and that difference is why it is a second context rather than a call into DetailPanelContext: it coexists with the docked Orbit panel — the file came out of that conversation, so closing the chat to show its own output would throw the context away. Hence TWO geometries, one per chat shape (useIsAgentHome in OrbitPanelContext is the single route test both consult): on the homepage — where the page IS the chat at full width — opening a file SPLITS the screen: AgentFileSurface (rendered in AppLayout's row) grows to the right half on the file-split-in/out width keyframes, which is the orbit-panel reveal move at half-screen — ONE animation resizes both sides, so the chat sliding left and the document growing can never race. On any other page the chat is the 26rem docked sidebar, too small to split, so AgentFileDock (rendered inside OrbitPanel's card, which is relative overflow-hidden) takes over the WHOLE sidebar with the drawer-in/out slide; the chat stays mounted underneath and X/Escape returns to it untouched. Below lg both are the full-screen bottom sheet. Expand (header, lg+) opens the document as a POPUP, not a takeover — the same frame the Statistics world map expands into: a bg-black/80 scrim with the card inset 1rem from every window edge and capped at max-w-[1600px] — the map's own numbers, so the two popups land at the same size on the same screen — scrim-click or Escape puts it back in the drawer (Escape peels ONE layer per press — popup first, then closed). It is a class swap, NOT a <Dialog>, and that is load-bearing: portalling the panel would REMOUNT it, and a remount mid-edit races the autosave (the unmount flush fires the pending PUT while the new instance seeds from a cache the PUT has not landed in, so the last ~800ms of typing silently reverts and the next save writes the reverted text). Hence the two always-mounted nodes doing double duty — the surface element becomes the scrim, the card wrapper around the panel becomes the popup card (SCRIM_CLASS / POPUP_CARD_CLASS). ONE right-side surface at a time on the homepage: the Activity panel and the document each yield to the other through the orbit handoff — registerYielder is now a SET returning its own unregister (the detail drawer and the file provider both enroll; a single slot had them overwriting each other), and opening a file closes an open Activity panel first (close, wait out the fold, then open — reacting after the fact is what flashes). Earlier wrong shapes, kept so they aren't rediscovered: a 42rem drawer over the page card left a ribbon of unrelated page text beside the document; hiding the page card left the chat pinned far-right instead of beside the document; and a floating half-card over the homepage glitched because its slide raced the chat's reflow.

Editors are chosen by KIND from the whitelist in lib/agent-files.ts, which mirrors file-index.js#fileKind — the backend refuses to serve (415) or save (415) anything outside it, so offering an editor for anything else would be offering a button that can only fail. csv → a real spreadsheet grid (file-editors/CsvGridEditor.tsx): visible cell borders, sticky header, numbered row gutter (number swaps to delete on row hover), drag-resizable columns (header right edge; widths seed from each column's own content, clamped 96–320px, session-scoped), add row — over lib/csv.ts, a real RFC 4180 parseCsv/toCsv pair, unit-tested (csv.test.ts), because a split on , would shred a quoted URL containing a comma and write the wreckage straight back to storage. md → the existing RichTextEditor, formatted, never raw ## — no new dependency: that component already IS the markdown round trip (formatContent in, its exported htmlToMarkdown out); MarkdownFileEditor only ignores the seeding onContentChange the editor fires while mounting (child effects run before parent effects, so a parent mount flag is the reliable line between "the editor is announcing itself" and "the user typed") — passing it through would autosave a normalized round trip the moment a file opened and mark the agent's own file edited. txt/json → a plain monospace text editor (file-editors/TextFileEditor.tsx); json additionally shows an inline invalid-JSON line — the edit still QUEUES (withholding keystrokes silently would be worse) and the route's 400 surfaces as "not saved" in the header's save line, so the last PARSING version is what's stored.

Autosave (hooks/use-agent-file-autosave.ts) is the whole safety story, since there is no save button: debounced 800ms after typing STOPS (a grid emits a change per character), ONE PUT in flight at a time (the route is a whole-file overwrite, so two overlapping writes are two writers of one object), a failure puts the text BACK in pending and shows "Not saved — <reason>" plus a toast rather than retrying in a loop, and the cleanup flushes on unmount so Escape / the X / a route change mid-debounce cannot swallow the last keystrokes. The panel is keyed by file id for the same reason: swapping files without a remount would hand the previous file's un-flushed edit to the new file's URL. Query keys: queryKeys.agentFiles.content(ws, fileId) (per-file, staleTime: Infinity — the panel is the file's writer while open, and a background refetch would undo live keystrokes) under the forWorkspace prefix. downloadAgentFile in lib/agent-files.ts is the ONE blob-download path, called by both doors (the chat card for non-openable types, the panel header for everything else).

Content

  • CollectionView — per-collection article list merging pipeline, GSC, and pixel data. Columns: status (+ amber needs_attention quality flag from the write-time output gate), indexed, impressions, visitors (MAX(pixel, GSC clicks)), leads, AI %, trend, published. Filters, search, sort, pagination; bulk publish/delete; New dialog (title + reference-page picker + archetype-driven fields). For article writes the dialog first calls the synchronous brief-preview (BriefGate.tsx): a gate rejection (duplicate / definitional TOFU) renders inline with the reason and nothing is queued; on pass the derived decisions show as a toast and the gated brief rides the queued writerSkill job. Below the list: discovered pages attributed to this collection.
  • BlogPostDetailV2 — the article editor: title/intro/meta (with char budgets)/cover/rich-text body/custom fields, all rendered per field role/type; auto-save (debounced 800ms, Ctrl/Cmd+S; every PATCH carries expectedUpdatedAt, and since 2026-09-09 that token moves ONLY through a server answer — a full article read via adoptArticle or a mutation's returned updated_at via noteUpdatedAt, never a local spread of the article object; a 409 is not retried); Publish flushes the autosave queue first and re-reads the article after, instead of racing a second PATCH; locale switcher; Publish / Approve & Publish / Republish (unpublished_changes) with CMS-capability-aware read-only fallback; delete; THE ACTION BAR IS CHROME, AND CHROME IS 28px (2026-09-01, Leon: "thinner"). The article editor's bar — status pill, language control, Share for approval, Publish/Republish, View live, copy — was a mix of h-8 literals and defaults; it now runs on h-control-xs, the height the Pages and Signals toolbars already use, so a detail page reads as the same app rather than a heavier variant of it. The ui-token ratchet is why this was cheap to verify: swapping the literals for the token dropped the raw-control-height baseline 274 → 268. ShareLinkButton moved too (its trigger hardcoded h-8; the article editor is its only caller, so the change is total, and the h-8s inside its popover are a different context and stayed). SitePageDetail took the same pass: it carried the article editor's old bar verbatim — five raw h-8 controls on full-strength border-border — so the two detail pages and the list toolbars they open from are now one height rather than three (baseline 268 → 263). The article breadcrumb also had a loading hole: its title override only set once the fetch resolved, so the crumb fell through to the raw URL segment (Article-9f3c…, a capitalised uuid) for the whole load and then snapped to the title, while the skeleton beneath it had already been rebuilt for the new layout. It now sets immediately with a Loading… placeholder and swaps in place — cheap only because the context's setters no-op on a same-value write, since that effect re-fires every render. The collection crumb stays absent until the article lands: it derives from article.collection_id, and guessing an attribution would put a wrong link in the trail rather than nothing. The language control is a DROPDOWN there, not the chip row (2026-09-01). LocaleSwitcher.tsx now exports two shapes for two jobs: LocaleSwitcher, the bordered-chip group with its optional populated dots, which is right where you EDIT across locales (brand voice, USPs, catalog — seeing the whole set and what is filled in is the point), and LocaleSelect, the app's standard filter control, for the article editor where the control is NAVIGATION — one active language, a list to switch to. A chip row there put every language permanently on screen beside the save state and five actions. LocaleSelect takes a footer slot rendered under a separator and OUTSIDE the item list (it picks no value, so it must not be arrow-key selectable as though it were a locale); the editor puts Sync languages there, because syncing is a thing you do TO the languages and belongs with them. SyncLanguagesButton gained an optional trigger render-prop for that: the caller supplies the opener, the component keeps the DIALOG, which owns in-flight and result state that has to outlive the menu closing behind it. The open is deferred a frame (setTimeout(open, 0)) so the closing menu and the opening dialog do not fight over focus. A page can now contribute a breadcrumb the PATH cannot express (2026-09-01, Leon: "make the content piece title appear after the collection"). The trail is built from URL segments, which works while the hierarchy is in the URL — but an article lives at /content/:id, a SIBLING of /content/c/:key rather than a child, so its collection appeared nowhere and the trail read Content › <title>. BreadcrumbContext gained parents: a {label, href} keyed by the path of the crumb it precedes, inserted by AppBreadcrumb just before that crumb. The article page sets it (it already resolves its collection for the publish URLs) and only for an ATTRIBUTED article — collection falls back to collections[0] for the field map, and a fallback is not a fact to put in a crumb that links somewhere. Both setters return the previous object on a same-value write, because they are called from effects that re-run on every render and a fresh object would re-render every breadcrumb consumer for nothing. THE RAIL replaced the side panels (2026-09-01, Leon). SEO, Article info, Statistics and Search Console were four buttons opening a sheet OVER the article, plus a fifth for Delete, all collapsing to unlabelled icons whenever any panel was open — the same control wearing two appearances depending on state that had nothing to do with it, with the labels vanishing exactly when you were deepest in the work. ArticleRail.tsx is now a permanent right column carrying SEO/AIO · Article · Strategy · Performance · Delete, and everything opens IN PLACE: a checklist is advice ABOUT the text, and you could not read it and the sentence it judged at the same time, which is most of why a checklist goes unread. The page took the signal page's frame with it — exactly, since 2026-09-03 (Leon: "same layout and spacing as the signal detail page"): max-w-4xl frame, editor at lg:max-w-xl, rail at lg:w-60 xl:w-64, py-10, the very dimensions SignalPageFrame declares, so an article, a signal and a storyline read as one family of detail pages. A one-day wider variant (max-w-5xl / lg:max-w-[39rem], 2026-09-01: "a signal page is read, this one is written in") was walked back for that sameness; BlogDetailV2Skeleton mirrors the same numbers and moved with it. The Back aside went the way the page detail's did — the chassis breadcrumb was already the way back, and here it cost a whole sticky column. ONE design at every width: below lg the identical ArticleRailContent renders inside a sheet behind a single Details button, which retired the parallel mobile toolbar, the old four-branch mobile sheet, activePanel/togglePanel/panelTitle/SidebarPanel, and the isMobile media-query state — CSS decides placement, so no JS knows the breakpoint. ArticleRailSection.tsx holds the collapsible section (state per storageId in localStorage) and the SEO/AIO body: all six real sections of computeSeoChecklist (Meta 20 · Title/H1 10 · Intro 10 · Body 43 · E-E-A-T 10 · Conversion) with a bar PER SECTION, so the rail says where the score is lost rather than only that it is; items nest inside their own section, marked by three SHAPES (tick / dash / cross) rather than a coloured dot — pass, partial and fail must be tellable apart in greyscale at 11px — and a passing check keeps its label but drops its reason, because fourteen explanations is how a checklist becomes noise. Renamed SEO/AIO because the checklist is already half about AI answers (TLDR-first intro, PAA coverage, FAQ answer depth, section passage shape, entity density, expert signal). ArticlePerformance.tsx reads BOTH sources itself (…/articles/:id/analytics + orbit-pixel/…/stats) and groups them by the question — From search, then On the page — rather than by which service measured them; rows, not tiles, because four tiles in a 15rem rail give each number a box louder than the figure. Strategy gathers what three cards used to scatter (BriefPanel's own bg-card box included): target search, topic, angle, shape, length, origin, the engine's note, and the brief's decisions — every prose value WRAPPING, since both old homes truncated the target search and "content agency pricing netherlands" clipped to "content agency pri…" hides the part worth reading. The heavy panels (ArticleInfoPanel, BlogArticleStatistics, AnalyticsPanel, BriefPanel) are unchanged and still exported; the rail simply no longer renders them. Meta fields moved BELOW the body under their own rule — they used to interrupt the writing surface between the intro and the first paragraph. The brief renders because parseArticle() maps it — it is required on ArticleData and the one canonical mapping used to drop it, so article.brief was always undefined and the panel silently never appeared. Type-checking the build is what surfaced that.
  • SitePages / SitePageDetail — server-paginated discovered pages with type/locale/status filters; detail shows identity, on-page facts, snapshot history, pixel audience, and an Index status block (2026-08-23): verdict + its age (gscLastInspectedAt rendered relative) + Google's coverage phrase + the canonical-elsewhere explanation + a Search Console deep link + Check now (POST /api/modules/gsc/:ws/recheck, then re-reads the detail so the derivation stays single-sourced). The status pill reads four states — Noindex (the page's own directive) wins over the rest, and indexed === null is Not checked yet, not "Not indexed"; collapsing them claimed a verdict Google never gave. THE DETAIL PAGE IS ONE CENTRED COLUMN (2026-08-31, Leon). It was a 3-column flex: a sticky Back aside, the content, and an INVISIBLE mirror of that aside whose only job was to undo the off-centring the first one caused — two layout columns for one button that was already redundant, since the chassis breadcrumb is the way back and this page feeds its own last crumb into it. Both asides are gone and the column centres itself, at the signal page's own head clearance and section rhythm so the detail pages share one top margin. The six stacked Cards became a local Block (label · optional count · optional window, then a hairline): six bordered panels down a single column is a lot of edges for content already in one column, and the KPI tiles — now listSurfaceClass, the Signals stat-card tile — are the only surfaces left. Type, locale and the crawl date moved out of the summary card's footer badges onto one line under the title with the URL, so what the page IS reads in one go. The pill's hardcoded blue-100/emerald-100/slate-100 (with hand-written dark variants — the only status here that would not follow a theme change) became bg-info/bg-success/bg-muted. Top searches left the shadcn <Table> for the app's own list rows (listRowClass + one shared QUERY_GRID string for header and rows). The Search Console deep link was promoted out of the indexing block into the action bar beside View live and given its label — it was an unlabelled icon half a page down, and it is one of the two places you actually leave this page for; Check now stays with the verdict sentence it changes. THE PAGES TABLE SORTS IN SQL, NOT IN THE BROWSER (2026-09-01, Leon: "every numbered column should be able to be sorted, and also the option to reset"). SitePages.tsx holds sort/dir state that travels through fetchSitePages to list_site_pages' new p_sort/p_dir, so the order is applied ahead of the RPC's own LIMIT. Client-side sorting was rejected outright on measurement, not taste: the list pages at 25 and the largest live workspace holds 10,159 pages (407 pages of results), with 12 of 17 workspaces over one page — a header that reordered the loaded slice would present the top of page 1 as the top of the site. SortHeader is the whole control: the label stays a header cell wearing listHeaderCellClass (density still moves from lib/list-styles.ts), and the arrow is the only affordance — hidden until hover on an inactive column so nine headings do not sprout nine arrows. The control that turns sorting on also turns it off: a first click takes the column's natural direction from SORT_DEFAULT_DIR (impressions and clicks desc, position ASC — rank 1 is the good end), a second flips it, a third clears back to the catalog's locale, url order; the "Sorted by <column>" button beside the filters is a shortcut, and it is kept SEPARATE from the existing "Clear" because a filter removes rows while a sort only reorders them, so one control doing both would overreach. Re-sorting resets to page 1 — staying on page 7 of the old order lands you somewhere arbitrary in the new one. sort/dir are part of the React Query key (via withFilters' opaque filters object), so each order caches on its own. Visitors has no control: the route aggregates it from the pixel after pagination, so it can only be ordered within one screen; get_content_inventory already sorts all four for the agent, which is where the whole-site answer lives. THE SAME SORT LANGUAGE COVERS THE ARTICLES LIST AND EVERY ANALYTICS BREAKDOWN (2026-09-01, Leon: "apply the same exact structure"). Same control everywhere: header text at rest, the arrow fading in on hover (previewing the natural direction) and staying lit on the ranking column, natural → flip → clear on successive clicks, a "Sorted by X" reset affordance. Three mechanically different implementations behind one feel, each matched to where its data lives. Articles (CollectionView.tsx): pure client-side, honest because the list loads whole and paginates in the browser; only measures sort — Status, Indexed and Lang went back to plain header text after a first pass wired them (Leon: "sorting on indexed, status, name etc make no sense right?"), each being a small closed set with its own FilterSelect inches above, so sorting one only clumps rows a filter already isolates; Visitors sorts by the same max(pixel, GSC) the cell renders (it was keyed to GSC clicks alone, misplacing every pixel-heavy article). Analytics breakdowns (BreakdownTable.tsx + the Pages tab's PagesTable, state in use-reports.ts#useBreakdownSort, per-dimension so Countries never re-sorts Devices): the sort rides the REQUEST (pixel_breakdown p_sort/p_dir — see the statistics module doc for the three-migration story, including the ratio floor built and removed the same day on Byve's evidence), because a truncated breakdown folds its tail into (other) and re-sorting returned rows ranks the survivors of a different question. The glitch pass (Leon: "a bit glitchy"): sortBreakdownRows mirrors the SQL ordering laws client-side so a click reorders on-screen rows instantly while the server settles in; swatch hues are pinned to the canonical sessions order (they were index-assigned, so every re-sort repainted the swatches and drifted them off the chart's series colors); the reset chip renders BELOW the rows because above the header it shoved the table down a line on the first click. The card-totals fix rode along: useCardTotals masks its data when no filter is live, because the app-wide placeholderData: keepPreviousData kept a disabled query serving the deselected pick's totals forever (the chart reset, the KPI cards did not). lib/site-pages.tsindexChip() is THE chip vocabulary — the SitePages desktop row and mobile strip held two copies of the same two-state ternary, which is exactly how a chip stays two-state when the data grows a third. A bare, ageless "Not indexed" was the whole bug: correct at write time, silently days old at read time, and offering the reader nothing to do about it.

Performance

  • Statistics — Orbit Pixel analytics (PixelStatistics); shared header with 7/28/90d range. (The Download-report button was removed 2026-07-22 — the PDF is parked pending its outcome-loop rebuild.)
  • Search Console — 4 KPI tiles with deltas, traffic trend, content-type pies, keyword rankings (top10/top20/rising/declining/new filters, expandable to page rows deep-linking into articles/pages).
  • AI visibility — Scan now, one KPI tile per surface (ChatGPT / Gemini / AI Overview / AI Mode), 4-line mention-rate trend (new surfaces start as gaps, not zeros), tracked prompts (cap read off the overview payload's cap, never retyped here — 15 since 2026-08-18; per-surface run dots; expandable answers + Edit question / Stop tracking / Delete, plus a collapsed Not tracked block that restores or permanently deletes a retired question, so retiring reads as reversible and deleting as not), "Where AI gets its answers" category-intel card (cited sources + untracked questions with one-click Track), "Visitors and leads from AI tools" proof panel (composes the orbit-pixel ai-referrals route — queryKeys.pixel.aiReferrals; hidden at zero), most-cited domains + competitors cards.
  • Backlinks — Refresh, KPI tiles (domain rank, referring domains ±30d, backlinks, spam score), trend, linked pages (Link magnet / Refresh-safe / No links; a retired URL that still earns links is a grayed row with a Redirects / Page gone marker — pageState off the row, PAGE_STATE_PILL — that opens where the redirect lands), referring domains, placement + TLD breakdowns.
  • Health — Run audit (live stages), score/verdict tiles, category gauges, mutable issue list, AI-access table, score trend, degraded-audit disclosures.

Settings (hub + 6 pages)

Page titles on the hub and all six pages wear the app's compact header size (text-base sm:text-lg, the same h1 every list page uses) rather than a page-local text-2xl.

Content settings carry Agent memory (AgentMemoryCard). (AutoPublishCard — the requireReview switch — was retired with the auto-publish lane 2026-08-12; the Goals card is GONE — S13 retired the goals system entirely; AgentGoalsCard and the agentGoals query key are deleted.)

  • Content — brand identity (per-locale), brand voice (per-locale, re-extract from site), ideal client, documents, search visibility, pages (refresh from site), competitor domains, agent memory (edit/forget), featured image. (The Lead value card is GONE — avgLeadValue retired 2026-08-20; value is per conversion goal, set in the Conversions panel.)
  • Integrations — GSC (+ Test connection), Orbit Pixel install + status, Conversions (ConversionsPanel), CMS configuration (platform, dynamic credential fields, Test connection, languages, Pause Content Generation).
  • Collections — one card per publishing collection: name, main URL(s) per language, CMS field mapping. Mapping requires a tested CMS; single-model CMSs capped at one collection.
  • Workspace — name, a Localization block (timezone via the shared components/TimezoneCombobox.tsx, plus the workspace display currency via the shared components/CurrencySelect.tsx — searchable by code or full name, on the SearchSelect recipe; the settings key avgLeadValueCurrency, PATCHed on save and the shared settings query invalidated so every mounted money surface re-renders; since 2026-08-20 it is purely the display currency — avgLeadValue is retired, value is per conversion goal), and People: the agency team (with roles, managed in /agency/members, not removable here), the Luniq platform master ("Luniq Support User", kind platform, labeled and not removable — shown honestly on every workspace, 2026-08-20), and guests (invited and removable here), split by a server-derived kind. No destructive actions: archive, delete, and re-run onboarding moved to /agency/workspaces, agency-admins only — a destructive button next to everyday config is how a client site gets archived by accident, and a guest should never see one.
  • Media — image library for covers (client-side resize + WebP re-encode).

Cross-workspace settings (outside /w/:workspaceId/)

Reached from the topbar user popover, rendered in PersonalLayout. Composed from components/settings/SettingsCard.tsx (SettingsCard / SettingsRow / SettingsFact / StatusText) — short pages a user reads, deliberately not the dense collapsible Section used inside workspace settings. The card header is items-center (2026-08-27): the title centres against whatever sits opposite it, so a card with a full-height button and one with a badge read the same; items-start left a taller action hanging below the heading. Every control on these pages uses the DEFAULT size — the h-9 inputs and size="sm" buttons were swept with the subtitles, since a page of shrunken controls next to a full-size one is the same drift the Members selects had.

RoutePageHolds
/homepages/Home.tsxThe empty-dashboard landing (replaced /onboarding's dead-ends): zero-workspace users land here (noWorkspaceTarget), and invitees' first landing (/home?joined=1) even when workspaces exist. Inside the page card: centered column over the outline mark. Since 2026-08-27 an ACTIVE agency never stays here — the effect sends it to /agency, its home, whether or not it has sites — and there is no approval step (an agency is active from registration; its plan is the gate), so what is left is: the invited member's first landing ("You've joined {agency}" + the workspace list, or a "you'll be added" note — members can't create), a suspended agency's notice (critical band + contact link; its workspaces stay listed, read-only), and an operator with no agency (ONE "Create your first workspace" button → /workspace-setup/new). The pending checklist and the "is approved" states are gone with the review gate. PersonalLayout shows no Back and no logo on /home (it IS the landing); for guests and suspended agencies, Back on /account returns to the workspace — or to /home when none exist; an active agency has no Back anywhere (its home tab is the way back).
/workspace-setup/newpages/WorkspaceCreate.tsxThe workspace flow's front door: name + URL on CenteredPage → POST /api/workspaces/create-workspace (the request also carries browserTimezone() so the new workspace's crons are local from day one) → the wizard with autoPrefill. The wizard's Continue lands in the site it just set up, addressed EXPLICITLY as /w/{id} (exitTarget) — a bare "/" now resolves to the agency's home for staff, which would drop you on the portfolio right after configuring one site. Bounces createGate.allowed === false visitors to /home; every Add-workspace entry (switcher, Agency → Workspaces) points here and only renders/enables for allowed callers. ?agencySite=1 (2026-08-29) is the AGENCY-SITE claim path from the hub: same form, agency-flavoured copy, the create call carries agencySite: true (the server stamps the included slot and skips the seat check), and the bounce reads agencySiteGate instead.
/account/*pages/account/AccountLayout.tsxthe person, in three tabs on the AgencyLayout shape (header + tab nav + Outlet, 2026-08-23): Profile (AccountProfile.tsx: name, password), Email (/account/email, EmailPreferencesPanel.tsx, 2026-08-23 — it replaced the per-workspace Settings → Notifications page and its apply-to-all route): the ONE cross-workspace mute (profiles.email_notifications_enabled, saved on toggle like the auto-publish safety switch) on top, then every workspace the person belongs to with a Manage button → dialog of per-type switches (GET /api/notifications/preferences reads all workspaces at once; PUT …/:ws saves on flip). Only emailed, non-always types are switches (today: the Monday overview); an always type (invites) ignores both the switches and the mute — and AI tools (/account/ai-tools, McpTokensPanel.tsx): the OAuth-approved MCP connections — copyable connection URL (VITE_MCP_URL when set) + list/disconnect via /api/modules/mcp/tokens (query key mcpTokens.forUser). No key creation — tokens are minted only by the consent flow. The Setup guide door (→ /docs/orbit-mcp, new tab) is the card's header action rather than a footnote under the field: connecting is a multi-step job per client, so the guide is the second thing you need after the URL.
/mcp/authorizepages/account/McpAuthorize.tsxThe Orbit MCP OAuth consent page — standalone (no shell). An AI client arrives with code+PKCE params; sign-in is INLINE (Login.tsx would navigate away and drop the params), then Allow/Deny → POST /api/modules/mcp/oauth/approve → redirect back to the client.
/agencypages/agency/AgencyOverview.tsxThe agency's HOME (2026-08-27): RootRoute lands an active agency with sites here, and it is the way back out of every workspace (sidebar logo, switcher footer, user popover). Every LIVE site as a tile — WorkspaceFavicon + name + domain, then ONE row: the signals present on its board in the board's own severity colours (signals on GET /api/agency/workspaces, {critical, warning, opportunity, info}, only the non-zero ones; null → "Signals not read", all-zero → "No live signals") and the WorkspaceStatePill. Each badge is the NUMBER ALONE — the colour says which severity, and four repeated words across twelve tiles is the noise the hub exists to remove; the word rides the tooltip + aria-label, so colour is never the only carrier. SeverityLegend spells the key out ONCE under the live grid (2026-08-28) — swatch + name + what it means (Critical losing traffic or revenue now · Warning getting worse, fix before it costs · Opportunity something to gain · Watch noted, no action yet), muted footnote over a hairline rule, no card: the numbers-only badge only scans if the key is on the page, and a tooltip is a per-tile answer to a page-level question. Colours and labels come from SEVERITY_PILL in lib/signal-board.ts, so the hub cannot drift from the board. No total count, no stages, no dates, no filter chips (Leon, 2026-08-27). Sorted by attention (critical ×100 + warning ×10 + opportunity), then name — with the AGENCY SITE pinned first (2026-08-29: isAgencySite on the list; the tile keeps the exact recipe plus a small bg-brand home mark on the favicon corner — identity, not a chip (Leon: the mark alone, no edge hairline, and no slot hints in the ⋯ menu). The mark is WorkspaceFavicon's agencySite prop, ONE place, so the switcher rows (pages/Workspaces.tsx, ws.id === agency.agencyWorkspaceId) and the sidebar's workspace card mark the same site identically; the tiles themselves now live in pages/agency/WorkspaceTile.tsx, extracted when this landed). While the included slot is UNCLAIMED and the viewer may claim it (agencySiteGate from /api/agency/me), a dashed AgencySiteGhostTile holds position one (hidden during search) → /workspace-setup/new?agencySite=1; admins also get ⋯ → Make this your agency site on client tiles (POST /api/agency/workspaces/:id/agency-site, frees the seat that site held). The WHOLE CARD is the button (no dead space on a thing that does one thing); the is layered over its corner as a sibling with stopPropagation, never nested inside it — a button inside a button is invalid HTML and would fire both. The lifecycle actions live here (absorbed from the deleted Workspaces page): Open · Open the board · Settings (→ that site's /settings) · Archive/Reactivate · Re-run onboarding · Delete, the last four agency-ADMIN only and still gated server-side by requireWorkspaceAgencyAdmin. Archived sites fold into a <details> group at the bottom. The tab's own controls (search + Add workspace) sit in the PAGE, not the layout header, and the whole toolbar is withheld until a portfolio exists — a search box over nothing is dead furniture. With no sites, EmptyHub renders the approved agency's first screen off the gate verdicts (agency site claimable → "Set up your agency's website" leads, the client-site button one step behind — the fresh subscriber's landing moment · allowed → add the first website · payment_required → set up the subscription · no_seats → add one or wait for Luniq's · not_admin → an admin adds the first), the states that used to live on /home
/agency/membersAgencyMembers.tsxthree labelled groups of tiles in the billing page's row/tile shape (You / Team / Invited), action outcome as an InlineBanner above the card. Your own tile sits first, read-only: role badge + a link to /account (profile settings); nobody changes their own role here. Others: role selects (admin/member), invite with a role, revoke, remove. The last admin's controls are disabled (the server refuses too). Explains that clients/freelancers are guests, invited from the workspace instead. Both selects use the DEFAULT select size (h-control, text-sm) — the same control as the /agency-setup wizard's role picker; the one-off h-7/h-9 + text-xs made one widget read as two (2026-08-27).
/agency/workspacesDeleted 2026-08-27, redirects to /agency. It was the third list of one portfolio; its lifecycle actions moved onto the hub's tile menus, keeping the same admin gate. Kept as a redirect because it is a learned address and a live bookmark
/agency/billingAgencyBilling.tsxtwo rows, Agency plan and Workspaces; a source line (Subscription · active / trial until / payment failed, or Assigned by Luniq · until <date> / no end date / ended) is listed ONLY when it carries something — the plan has one carrier at a time (the subscription when there is one, else the assignment), assigned workspaces show while they still count, an ended assignment only when it explains an over-capacity state. Each line carries its price on the right ($149/mo · 3 × $199/mo · Included); no card subtitle; a Monthly footer with the total and the pricing name + currency (2026-08-27). ONE lever, the card's action button Set up subscription / ChangeSubscriptionDialog (components/agency/): agency plan always included, a QuantityStepper for workspaces (floor = live − assigned), the monthly total; submits to Checkout (returnTo: 'billing') or POST /billing/seats. Buying workspaces stays possible on top of an assigned plan. The billing verdict and the outcome of the last action render as InlineBanners ABOVE the card (components/InlineBanner.tsx — the same primitive as WorkspaceGateBanner and the agency layout's band, so a warning looks the same everywhere). Manage billing on the Payment card → the portal. ?checkout=success triggers POST /billing/sync before the refetch (2026-08-27)
/agency/brandingAgencyBranding.tsxthe agency name first (AgencyNameCard, admin-editable via PATCH /api/agency — moved here from the overview 2026-08-27: it is identity, so it sits with the marks), then white-label: one upload per slot (wordmark + optional square mark). Server normalises into a fixed box, so no dimension inputs; dark mode is decided for them and stated in plain language, with both themes previewed on the real surface colours through BrandLogoArt. The per-theme upload is an escape hatch folded behind "Not happy with dark mode?". Colour is still an honest empty note (WHITE_LABEL_PLAN §5: the platform stays neutral; colour is for public pages)

AgencyLayout.tsx owns the section rail and the three viewer states: admin (edit), member (read-only), guest (GuestView — "this workspace is managed by X", an explanation rather than a 403 screen, since having no agency is what being a guest means).

THE AGENCY HEADER IS INVARIANT (Leon, 2026-08-27). AgencyLayout renders the greeting (the workspace home's own line — Good morning, {first name}) and the section row, identical on Home, Members, Billing and Branding; every tab's own controls live INSIDE its Outlet body — the hub's search + Add workspace, Members' invite row, Branding's Save. There is deliberately no per-route title or action slot: moving between tabs changes the body and nothing above it, which is what makes the row read as sections of one page rather than links to four different ones. Subtitles are gone product-wide in this pass (the role line under the agency name, the Account intro, every SettingsCard description on the agency and account pages): a title and the control, nothing under them. SettingsCard keeps the description prop for the workspace-scoped surfaces that still pass one.

RoutePageHolds
/legalpages/Legal.tsx (LegalIndex)the published catalog: title, audience, version, effective date
/legal/:documentIdpages/Legal.tsxone document's text

Public and sessionless on purpose — terms have to be readable before anyone signs up. The text comes from /api/legal/documents/:id, never from the bundle, so a reader always sees the version actually in force rather than whatever shipped with this build. Both pages render on CenteredPage at the lg reading width, and document tables use the docs' block design via components/MarkdownTableBlocks.tsx — extracted from DocViewer so the two surfaces cannot drift.

There is no in-app acceptance gate (the LegalGate dialog was removed 2026-08-18 — invited guests could not get past it, and Leon's call is that agreement is by continuing, with nothing to tick). components/LegalNotice.tsx is THE legal surface: the "By continuing, you agree to…" footer line under the forms where a relationship starts — /register (customer variant: Terms of Service) and /join (audience follows the invite kind: agency member → ToS, workspace guest → Platform Access Terms); one component replacing four copies. /login carries no notice — the person already agreed at sign-up. Every link into /legal opens in a new tab — from the sign-up footer (which sits under a half-filled form) and from the in-app docs. DocViewer's DocLink grew an absolute-path branch for it: a markdown /legal link used to fall through to the sibling-slug branch and resolve to a broken /docs//legal. See Legal for the model.

Docs viewer (/docs, public, outside the app shell)

pages/docs/DocsLayout (resizable sidebar, ⌘K search) + DocViewer rendering the registered pages from frontend/src/docs/index.ts. The viewer owns a small set of block primitives beyond plain GFM: GitHub-alert blockquotes (> [!TIP]/[!NOTE]/[!WARNING] callouts, > [!ASK] copyable question chips, > [!APP] "In the app" cards) plus two per-doc opt-in layouts from the registry (cardGrid: an H2's ### children render as a card grid; steps: ordered lists render as numbered step blocks). Everything is plain markdown in the source files so the agent's product-docs snapshot stays readable text. Authoring rules and pipeline gotchas: Keeping docs updated.

DocsAssistant.tsx is the AI ask layer on the same pages: a bottom-center ask bar over the doc card; the first question opens a right-hand chat panel (the bar hides while the panel is open — the panel has its own input). The panel opens/closes with the main app's OrbitPanel animation (orbit-panel-in/out docked reveal at md+, orbit-sheet-in/out full-screen slide-up below, mounted-while-closing so the collapse plays), and messages scroll UNDER the composer behind a bottom fade shade rather than clipping at its edge. It runs the same follow/detach scroll model as AgentChat (below) — a naive snap-to-bottom per message glitched the user back down mid-generation whenever they scrolled up to re-read. Both inputs are the main-app composer design. One lean component + useDocsAssistant hook — NOT a reuse of the workspace AgentChat (no threads/jobs/approvals). It streams POST /api/agent/docs-chat (public SSE, docs-only tool loop — AI agent), resends the whole conversation each turn, and keeps history in sessionStorage only. Assistant replies render markdown with internal /docs/<slug> links mapped to router Links, so citations navigate the viewer in place.

Shared pages (/s/:token, public, outside the app shell)

pages/share/ — ONE page for every kind of share. SharePage.tsx fetches /api/share/:token with plain fetch (no session to attach), wraps it in PublicShell (branded header, content column, footer) and dispatches on kind through pages/share/kinds/index.ts. A new shareable surface adds a renderer + one registry line; nothing else changes.

Two renderers today. SignalsReview — the board's In review stage, one decision per item. ContentDraft — ONE content piece in review (content_draft, minted from the article editor's Share for approval button whenever the piece is pending_review): the document open and whole via react-markdown + remark-gfm in a prose block (no raw-HTML plugin, so a piece can never inject markup into the agency's page), cover image, the collection's own fields read-only under the document (Details — the mirror of the editor's ContentFields, drawing from the kind the server resolved instead of repeating a type switch), the search-result preview behind the same <details> the evidence block uses, and one Approve button. Multi-language pieces get a language tab row (full names from the payload, brand-toned — deliberately not the app's LocaleSwitcher, which is built on primary tokens and would paint Luniq's accent onto the agency's document); the markdown block is keyed on the language so a switch resets scroll instead of landing mid-way into a piece of a different length. Approving covers every language and says so.

Approving never publishes — it tells the agency the client is happy, which is why the editor's publish button reads plain Publish for agency staff and keeps the one-click Approve & Publish for everyone else (approvesOnPublish, viewer.isAgencyStaff). The approval comes back into the app three ways: a "Client approved" badge + timeline row in ArticleInfoPanel, and ShareLinkButton's optional status prop, which turns the trigger itself into the answer (emerald + Check + "Client approved", date inside) — the caller resolves it, the component only wears it.

Renderers receive { data, act }; act(action, itemId?) POSTs to the public action endpoint, so a kind never sees the token. The query is staleTime: Infinity with focus/reconnect refetching off: a shared page is a document, fetched once and then read, and a background refetch swaps content out from under someone mid-decision. For the same reason the renderer keeps approved ids in page state, not per-card state (a card that owns its own flag loses it on any re-render of the list, and the client watches their tick turn back into a button), and approved items stay in place rather than moving to the approved group while the page is open.

Theme: storedTheme() (hooks/use-theme.ts), not useTheme(). The difference is the point — useTheme falls back to the OS preference, and a reader who is not an Orbit user would then receive their agency's report in dark, a look the agency never saw and cannot preview. Dark only for someone who explicitly chose it in Orbit. PublicShell pins the palette on its own wrapper (.theme-light / .dark) instead of inheriting <html>, so the logo treatment can never disagree with the surface underneath it. There is no theme toggle on the page.

The colour comes from components/brand/BrandProvider.tsx, which sets the --brand* custom properties as inline styles on one wrapper — so bg-brand / text-brand / bg-brand-strong work inside it and nowhere else. The derivation (lib/brand-tokens.ts) parses the agency's hex into OKLCH, clamps lightness/chroma into a readable band, and computes --brand-foreground from a real WCAG contrast test. There is no tinted-surface token: a wash of an arbitrary hue behind small grey text is where an unlucky colour first stops looking clean, so brand colour is limited to solid fills (which carry --brand-foreground) and accent text, and panels stay on bg-muted. Status colours stay semantic — a green "opportunity" never becomes the agency's red.

The one brand colour INSIDE the app is the Analytics world map (2026-08-16). mapRampStyle (same file) derives the six --map-* steps at the agency's hue and CountryMap sets them inline on both its panel and its fullscreen dialog — both, because the dialog is portalled to document.body and would otherwise inherit nothing and fall back to blue. It is a deliberate exception, not a crack in the rule: a choropleth's fill IS the measurement, so a neutral one wastes the channel, whereas chrome tinted with an arbitrary hue is the failure mode the rest of this section exists to prevent. The safety is that only the HUE moves — the lightness/chroma ladder is lifted from the blue ramp in index.css, measured in OKLCH, so monotonicity, step separation and the pale end's distance from the neutral --map-empty hold for any hue. Validated with the dataviz skill's --ordinal checks across eight brand hues in both themes: every derived ramp scores identically to the shipped blue. --map-empty is never derived — absent data must not wear anyone's colour.

components/share/ShareLinkButton.tsx is the agency-side control (mint-on-first-click, copy, revoke). It renders only for viewer.isAgencyStaff; the backend gate (requireWorkspaceAgencyStaff) is what actually enforces it. First placement: the Signals board's In review tab. See Shares.

Onboarding wizard (WorkspaceSetup.tsx)

Entered after workspace creation (site scraped to pre-fill) or via Re-run onboarding. 20 steps: Welcome → Company name → What you do → Industry → Market → Languages → Products → Ideal client → Localization → Search visibility → Competitors → Voice rules → Documents → Connect CMS → Collections → Tracking pixel → Conversions → Analytics (GSC) → AI setup (background /setup with live stage polling) → Done. Push targets and Key pages were cut 2026-08-28 (Leon: consolidate into where the value is): the page picker wrote focusPageIds, which nothing but the key-tier pin and get_workspace_profile ever read (the writer never did), and the Key-pages step reviewed a set the nightly scoring recomputes anyway. Discovery still starts from the first form step (lib/site-pages.ts#ensureSiteDiscovery, fire-and-forget: skip if a run is active or a key set exists, else POST /discover; hooks/use-site-discovery.ts and its polling are gone) so AI setup enriches a real catalog. Manual page add went with it (Leon: simplification, not expansion) — POST /page, addManualPage and the source='manual' pin are deleted; discovery is the only way a page enters the catalog. The prefill scrape now also SUGGESTS visibilityTargets (same single Haiku call in scrapeWebsiteForPrefill, same day): 3–6 buyer queries in the site's main language, |-separated in the model output; runPrefill drops them into the chip list ONLY when it is still empty, so a saved list is never overwritten. Competitors were tried and dropped the same day: from a homepage read the model named tools (webflow, hubspot) for an agency and UK/SE/US roasters for a Belgian one — the Market module's SERP discovery is the honest source. The Languages step preselects nothing (2026-09-02): the buttons come from the saved settings or the prefill detection, and an English-only fallback with the shared components/AddLanguageButton.tsx picker (extracted from Settings → Integrations) keeps every language reachable when detection found none. The old hardcoded ['nl','en'] client default shipped a Dutch profile to an English-only workspace when a mid-wizard reload discarded the in-memory detection — detection itself had said en. The detection is also persisted server-side at prefill time (persistDetectedLanguages, fill-only: a saved value is never overwritten), so a reload can no longer lose it. Every input autosaves (debounced diff-PATCH); re-entry restores every field from saved settings. Mount-time resume routes on onboardingStatus: running/failed → the processing screen; complete splits on the SAME completeness definition the readiness banner uses (useContentSettings().settingsComplete) — fully set up → the Done screen, anything missing (the banner's Finish setup entry) → the first form step, pre-filled, in finishSetupMode: the GSC step's button becomes Finish setup and jumps straight to Done instead of re-POSTing /setup, so a finished AI pipeline is never re-run just to add GSC or the pixel. The Tracking step shows the shared components/OrbitPixelInstall.tsx (same block as Settings → Integrations) with live install status + an on-demand Check installation button against /api/modules/orbit-pixel/:ws/status; the block is script + paste instructions only — the what-it-tracks / define-conversions explainers were cut (user docs + the Conversions step cover them). The Localization step (2026-08-20, replacing the Lead value slide — value is per conversion goal now) is setup-slides/LocalizationSlide.tsx: the workspace timezone (a workspaces column, not a settings key — shared components/TimezoneCombobox.tsx, the same picker Settings → Workspace renders; state seeds from the row or the browser and saves on advance via updateWorkspace, fire-and-forget) plus the display currency (shared CurrencySelect, settings key avgLeadValueCurrency, rides the wizard autosave). The Market step is countries only (CountryMultiSelect). A CMS is optional (2026-08-27): the Connect CMS step is the shared components/CmsPlatformTiles.tsx grid (the registry's platforms plus a real "No CMS yet · Drafts only" tile, value "", selected by default — cmsType no longer defaults to payload, so a skip saves null instead of a half-filled platform) + the credentials + components/CmsConnectionStatus.tsx, one line driven by hooks/use-cms-connection-check.ts (the check fires by itself ~800ms after the last required field is filled; "connected" = credentials complete via lib/cms-credentials#isCmsConnected, the check is feedback). Settings → Integrations renders the same tiles and the same line. The Collections step's Continue waits for one NAMED card (CollectionsStep onReadyChangeCollectionCard onNameChange; a name alone saves), because at least one collection is the readiness rung; every card shows its mode — Publishes to X / Drafts only — from lib/publishing-mode.ts (the live mirror of workspace/collections/publishing.js) while editing and from the row's server-computed publishing once saved, and the article page swaps the Publish button for a one-line "Draft. This collection doesn't publish yet: …" when collection.publishing.publishes is false.

Operator surface

There is no separate admin dashboard in this frontend (operator controls live behind backend /api/internal/*). Per-workspace module enablement is GONE (2026-08-14) — every module is available everywhere, useModuleEnablement was deleted, and only the soon flag grays a nav entry. soon is static for genuinely unbuilt surfaces (Social, Sales) and ENV-DRIVEN for a built one still being soaked: src/lib/features.ts holds one flag per such surface, ON wherever the variable is unset (local, staging, previews) and closed only by an explicit override on the deploy that should not have it — VITE_MARKET_LIVE=false in production today. A grayed nav entry does not close a route, so the page component carries the same gate and returns the "Coming soon" panel before it fetches anything. User-facing manual triggers: Scan now (AI visibility), Run audit (Health), Refresh (Backlinks), Refresh from site (Settings), New article, Re-run onboarding, Pause agent, Pause Content Generation.

Conventions

  • Query keys only via the lib/query-keys.ts factory, workspace-scoped — never inline a key array. One key = one response shape. Two queryFns may share a key only when they return the identical object (the collections list, the health overview); a narrower read gets its own key under the same prefix (aiVisibility.prompts, sitePages.counts) so invalidations still reach it. The 2026-08-28 crash (tracked.data ?? []).map is not a function was an array and an object sharing aiVisibility.overview. All API calls via lib/api.ts / lib/auth-fetch.ts (token attach, single-flight 401 refresh). authFetch caches the access token in module scope, fed by onAuthStateChange and its own refresh, and bypasses the cache within a minute of expiry — it used to await supabase.auth.getSession() (a navigator lock + storage read) before every request, a visible hop on every page switch.
  • Navigation is instant by construction, not by page-level effort (2026-08-28). It is also SYNCHRONOUS: BrowserRouter runs without v7_startTransition, because a transition holds the old screen until the next page has fully rendered (chunk + render), which read as the click being ignored. The URL, sidebar highlight and breadcrumb flip on the click; whatever is still loading shows its skeleton at once. The QueryClient defaults in App.tsx are staleTime: 30s, gcTime: 30min, placeholderData: keepPreviousData, focus refetch off: a revisited page renders from cache and refreshes behind its content; a query whose key changes (range, filters) keeps showing the previous result instead of dropping to a skeleton. A fresh mount has no previous result, and AppLayout keys the page tree on the active workspace, so the placeholder can never show another workspace's data. Pages fetch through React Query, never useState + useEffect (the Search Console page was the last one and was ported: queryKeys.gscPage.*); a first load shows a skeleton on the list surface (ListRowsSkeleton, BlogListSkeleton, MetricsSkeleton), never a spinner or a sentence. Redirects live in shell/routes.tsx (WorkspaceScopedRedirect), not in a page's mount effect — the effect version cost two route transitions per click. The one data-dependent exception is bare /content, which resolves to the last-used collection once collections load.
  • A skeleton is the page's own shape, never a generic placeholder. components/DashboardSkeleton.tsx is the library (PageHeaderSkeleton, TabBarSkeleton, MetricTilesSkeleton 4/6/8 on the pages' exact grids, MetricsPageSkeleton, ListRowsSkeleton with the page's own column band passed as header, SettingsFormSkeleton/SettingsTilesSkeleton in the centred max-w-3xl py-10 frame, SignalPageSkeleton mirroring SignalPageFrame, SitePageDetailSkeleton/BlogDetailV2Skeleton mirroring their pages' frames). A list page renders its column band ONCE as a headerBand element and hands it to both the list and its skeleton, so the first row lands in the same place either way. Counts read "–" while loading, never 0. No spinner or sentence stands in for a page or a list; spinners are for a button's own action only.
  • One chunk per page (2026-08-28). shell/route-loaders.ts holds the import() per workspace page; shell/routes.tsx wraps each in lazy() and its OWN Suspense via page(Component, fallback), the fallback drawn in that page's shape (above), INSIDE the page card (the shell never blanks); the outer boundary's PageSkeleton covers anything unlisted. The sidebar's NavLink calls prefetchRoute(path) on hover/focus/touch-start, so the chunk is usually cached before the click. The home (Strategy) stays eager. App.tsx lazy-loads the standalone surfaces (docs, audit, share, legal, MCP consent, setup wizards) behind the auth gate's full-screen spinner. vite.config.ts names three vendor chunks (vendor-react, vendor-radix, vendor-supabase) so a deploy that touched only pages leaves them cached; everything else Rollup splits per route. A tab from a previous build that fails to load a chunk reloads once (main.tsx, vite:preloadError).
  • hooks/use-workspace-context.ts — ONE query for "what does this person see in this workspace": { modules, surfaces, viewer }. useModuleEnablement() (nav graying) and useWorkspaceSurfaces() (board visibility) are thin reads of it, so they can never disagree. Resolves optimistically while loading — everything enabled, board visible — so the nav never flashes modules off and an agency's console never blinks out; the server refuses anything this would have hidden, so being generous is safe. resolved is the one exception, and it exists for branding: optimism is right for surfaces and wrong for a brand, so BrandLogo renders an empty reserved box until the context lands rather than flashing Luniq's logo at a white-labelled agency's client. shell/SignalBoardRoute.tsx guards /signals with it (redirects home rather than rendering an empty board), AppLayout hides the topbar Signals entry, and AgentChat skips the home signals query entirely.
  • components/DrupalTranslationsPanel.tsx (+ hooks/use-drupal-translations.ts, queryKeys.drupalTranslations) — Drupal only, rendered in both the wizard's Connect-CMS step and Settings → Integrations, as ONE component so the two can never tell a customer different things. Drupal core cannot create a translation over its API, so a multilingual Drupal needs a module on the customer's site; the panel probes it and renders four states separately (not installed · installed but the permission is missing · ready · unreachable) because each is a different job. Download goes through authFetch + a blob, not an <a href> — the route is authenticated and a plain link returns a 401 page named like a module. Check again is never gated on configured: in the wizard the credentials are typed after the panel mounts (they reach the server through the step's autosave), so a disabled button would strand someone on "not connected".
  • hooks/use-setup-complete-map.ts — "is this workspace fully set up?" for many workspaces at once, the amber dot on the switcher AND on the /agency tiles. One definition (getMissingFields), so a workspace can't read as ready in one list and unfinished in the other.
  • hooks/use-agency.ts (useAgency()) is the frontend's one read of the caller's agency + role: { agency, isGuest, isAdmin }, keyed by user under queryKeys.agency.*. agency === null IS the guest check — there is no guest role to read. It gates what's worth rendering (archive/delete sections, the agency door, admin-only controls); the server independently enforces the same split, so the UI is never the security boundary. pages/Workspaces.tsx (WorkspacesDialog) — the switcher, opened from the sidebar card or ⌘K / Ctrl-K anywhere in a workspace (bound in AppSidebar). ONE alphabetical list (a "recent" group was built and cut same-day, Leon 2026-08-27: with search on top and one-line rows, an ordering that shifts under you is harder to use than one that never moves), each row the shared WorkspaceRow with the same favicon + WorkspaceStatePill language as the hub. Switching KEEPS YOUR SECTION (sectionPathForOtherWorkspace rewrites only id-bearing detail routes to their list). Its footer is one row of two doors: All workspaces/agency (staff only — a guest has no hub) and Add workspace (only when the server's create gate allows).

useAgencyWorkspaces() (same file, 2026-08-27) is the ONE read of the agency's workspace list — GET /api/agency/workspaces under queryKeys.agency.workspaces — now with a single consumer, the /agency home, since the Workspaces page it was extracted to share with is gone.

  • ContentFields.tsx — ONE widget per field type (richtext → RichTextEditor, media → ImagePickerDialog, text+maxLength → counter input, select/relationship → dropdowns/chips). Widgets follow the type, never the role — role only decides where the value binds.
  • Archetypes (useArchetypes) — the content-type registry (goal, item noun, writer skill, newItemForm) drives the New dialog; adding a content type needs no per-type UI code.
  • Placementshell/ is chassis; product pages under pages/orbit/<area>/. A new module's UI is its own sub-page, never more tabs in Content.tsx/Performance.tsx. CollectionView.tsx and BlogPostDetailV2.tsx are extract-only.