Theming engine + BO-10 (one DB pool, non-blocking audit) - #379
Merged
Conversation
Research + architecture scoping for switchable UI themes (colors, fonts, icon packs, component personality) across the Control Plane: - Audit of existing foundations (shadcn-style tokens, next-themes, Tailwind v4 @theme inline) and the gaps blocking multi-theming - Recommended token-driven architecture: data-theme scopes, semantic <Icon> abstraction, next/font family switching, org/user persistence - OSS evaluation: tweakcn, Iconify, @fluentui/react-icons, material-color-utilities, daisyUI, Style Dictionary (adopt/borrow/skip) - Example Fluent (Microsoft) and Material (Google) theme manifests - Phased implementation plan with estimates Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Adds a theming engine that restyles the entire Control Plane — colours, fonts, corner radius, effects and icon pack — from Settings > Appearance. Four themes ship: RapidTool (the existing design, preserved token for token), Fluent (Microsoft Fluent 2), Material (Google Material 3) and Graphite (low-distraction monochrome). Architecture - A theme is DATA: a manifest in src/lib/theme/themes.ts. Adding one needs no component, CSS or Tailwind change. - css.ts compiles manifests to html[data-theme="..."] custom-property scopes, inlined once in the head, so switching is a single attribute write with no fetch and no flash. The html qualifier puts them at specificity (0,1,1), above the globals.css fallback at (0,1,0), so generated themes win regardless of stylesheet order. - Style (data-theme) and mode (the next-themes .light class) are independent axes; every theme defines both modes. - A pre-paint boot script applies the stored preference before the first frame, mirroring how next-themes handles light/dark. - The Tailwind radius scale now derives from --radius, so one value controls the app's roundness. At the default 0.75rem it resolves to exactly Tailwind's own defaults, leaving the shipped look unchanged. - tech-glass/glow/transition and the scrollbar, shimmer and selection colours are token-driven, which also removes the .light re-tint special case: any theme's light mode now gets readable glass automatically. Icons - New <Icon name="Plus" /> renders from the active theme's pack. Lucide names are the shared vocabulary, so migrating a call site is a one-line edit and any of Lucide's ~1,600 names still works. - Fluent and Material packs are pruned Iconify collections (187 icons, ~80 KB each) generated by scripts/build-icon-packs.mjs, which resolves every name against the real collections so a bad mapping fails loudly. They load lazily, only when a theme needs them, and render offline. - All sidebar and landing-page glyphs migrated. Unmigrated call sites keep rendering Lucide, so the migration is incremental and never breaks. - resolveIcon() stays hook-free: server components and iconSvg.ts's static-string rendering cannot run hooks. Preferences - Resolution order: member override, org default, built-in default. - Personal choices are per-browser; the org default comes from /api/settings/appearance and is cached locally so it survives first paint. Admins can lock the org to a single theme. - The route validates every field before forwarding — these values drive a CSS selector for the whole org, so a bad write breaks the app for everyone. Accent colours are restricted to plain colour literals and applied through the CSSOM, never concatenated into stylesheet text. - Falls back to built-in defaults with orgManaged:false when the gateway has no appearance store, so the feature works without backend changes and the UI explains why org controls are unavailable. Testing - 37 unit tests over manifests, CSS generation and the icon registry, including a drift guard that parses globals.css and fails if its no-JavaScript fallback diverges from the default manifest. - 14 browser tests asserting computed styles and real glyph swapping — the only place CSS cascade order can actually be verified. Not included: shared Button/Input primitives and the remaining ~155 files that still import lucide-react directly. Those already theme correctly for colour, radius and font; they keep Lucide glyphs until migrated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Two follow-ups to the theming engine: the company-wide switch now has a backend, and a theme can no longer ship unreadable. Org-wide default - New org_settings table (145_org_settings.sql): a generic key -> JSON store for settings owned by the organisation rather than a person or a subsystem. Separate from model_config because appearance is not model configuration, and filing it there would make that table mean "any config we needed a row for". - acb_common.org_settings mirrors acb_llm.model_config: sync psycopg, so it works from both sync helpers and async handlers. Reads degrade to a default (a preference that will not load should not break the page); writes raise (a silent no-op would tell an admin their change to everyone's UI had applied when it had not). - GET/PUT /settings/appearance on the gateway. Reading is open to any signed-in member, since every client needs it on load; writing requires admin:settings:manage, since it changes everyone's UI. The row records who changed it and when, and Settings surfaces that. The gateway deliberately does not know which themes exist. themeId is stored as an opaque, selector-safe string, because validating it against a copy of the frontend's THEMES list would mean a backend deploy for every new theme, and a mismatch between the two lists would reject a theme the app can actually render. Both layers validate independently and both fall back per field, so a blob written by a different version of the app degrades one key at a time rather than resetting the company's look. Contrast gate - contrast.ts implements WCAG 2.1 ratios over the colour forms the manifests use, returning null rather than a wrong number for anything it cannot parse, so an unchecked colour cannot masquerade as a pass. - contrast.test.ts measures every theme x mode x text-bearing pair and fails below AA. Verified in both directions: an injected bad colour fails with the offending pair and ratio named, and fixing a recorded shortfall fails until its entry is deleted. - Two shortfalls in the new themes are FIXED, not recorded: Fluent light primary (4.44:1) and Graphite light accent (3.71:1). - Seven pairs in the original RapidTool palette are below AA. Those are the app's shipped colours, and changing them is a brand decision rather than a side effect of adding a test, so they are recorded as a ratchet with their measured ratios: they may improve, never regress. New themes get no such latitude. Verified: 172 frontend unit tests, 22 new gateway tests, 3932 existing python tests unaffected, production build clean. The migration and the full store -> route round trip were exercised against a real Postgres 16, including idempotent re-apply, upsert, an unknown-but-valid theme id surviving, and a corrupt row degrading to defaults. NOT done: infra/postgres/schema.generated.sql is unchanged. Replaying all 145 migrations needs the pgvector and age extensions, unavailable in this environment, and the file must never be hand-edited. Run scripts/apply_migrations.sh then scripts/dump_schema.sh on a real deployment and commit the refreshed snapshot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Completes the icon half of the theming engine. Before this, the icon pack
swapped in the sidebar and landing page only; everywhere else icons stayed
Lucide regardless of theme. Now 158 files render through <Icon>, and the
only two modules still importing lucide-react are the two that must:
Icon.tsx (the primitive, which falls back to Lucide) and lib/icons.tsx
(the resolver used by server components and by iconSvg.ts's static-string
rendering, neither of which can run hooks).
Registry
- 88 more icons mapped, so all 251 Lucide icons used anywhere in the app
resolve in both the Fluent and Material packs. Packs are still pruned:
275 icons, ~115 KB and ~106 KB, fetched only when a theme needs them.
New API
- themedIcon(name) returns a memoised component bound to one name, for the
many places that keep icons in a lookup table rather than rendering them
inline. Memoised because the return value is a component TYPE: a fresh
function per call would remount the icon every render.
- ThemedIcon is the type those tables annotate with. `typeof` on a call
expression is not valid TypeScript, so the 15 `typeof SomeIcon` type
positions became this instead.
- IconProps gained fill/color/onClick, which lucide forwarded and call
sites rely on (a filled star renders hollow without `fill`).
Leaky APIs fixed
- Tabs took `icon?: LucideIcon`, which pinned every caller's tabs to one
pack. It now takes an icon NAME, matching the documented pattern.
- Nine other components typed icon props as LucideIcon; they store icons
in tables, so they take ThemedIcon.
- Meta tables in the tool-card, agent, integrations and email-automation
screens now hold icon NAMES rather than bound components. Those tables
were built inside render functions, which is indistinguishable from
creating a component per render — a name is simpler and the lint rule
agrees.
How this was done, and two failed attempts worth recording
- The migration ran as an AST codemod over the TypeScript parser. Two
earlier regex versions were written, run, and reverted, because both
produced code that LOOKED migrated:
* masking "JSX text" as `>…<` blanked real statements, since `>` is
also a comparison operator — `now > prev` opened a bogus region that
swallowed everything to the next brace;
* masking string literals by quote blanked whole JSX blocks, since an
apostrophe in prose ("don't") opens a fake string.
Both hid references from the rewrite AND from the leftover check, so the
damage was silent. An AST cannot confuse code with prose.
- The codemod also handles aliased imports (`Workflow as WorkflowIcon`
must emit the lucide name, not the alias) and files with their own
`Icon` binding (imported as `AppIcon` instead of skipped).
Verified: typecheck clean; 350 unit tests pass; production build clean;
14 browser tests confirm all 28 sidebar glyphs swap to Fluent's 20px grid
and Material's 24px grid and back. Lint is better than baseline on both
counts (118 errors vs 120, 83 warnings vs 85).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Code views were the last surface still branching on dark/light only, so a code block looked identical on Fluent and Material while everything around it changed. Monaco and Shiki each ship a closed set of named themes and cannot be driven by our CSS tokens, so a theme now names its equivalents: Fluent → VS Code's own dark-plus / light-plus Material → material-theme-darker / material-theme-lighter Graphite → min-dark / min-light, plus Monaco's high-contrast themes RapidTool → github-dark / github-light (unchanged in effect) Call sites read useMonacoTheme() / useShikiTheme() instead of comparing resolvedTheme, which saw two states where the app has eight. A unit test checks every name against Monaco's built-ins and the installed Shiki bundle, and was verified to fail on a bad name — a typo here is invisible until someone opens a code view, where Monaco silently falls back and Shiki throws at render. xyflow's colorMode deliberately stays dark/light: it drives only that library's own chrome, and our nodes already use our tokens. Also records, in the scope doc, why infra/postgres/schema.generated.sql was NOT regenerated despite the migrations being applied and verified: a clean replay produces 71 fewer tables, because that snapshot came from a Postgres shared with LiteLLM, Langfuse and mem0. Overwriting it would delete real schema. It is separately stale by 86 tables — a pre-existing chore needing a dump from a real deployment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
…just colour
Until now a theme could change a button's palette, radius and font, but every
button still BEHAVED identically. That is the gap this closes: Material 3
renders pills with a translucent state layer on hover, Fluent strokes even
its solid buttons and weights labels heavier, Graphite upper-cases them.
None of that is expressible as a colour, and none of it can live in a class
string copied across call sites — a state layer needs a pseudo-element, and
text-transform has to come from a custom property so a theme can change it
without touching a call site.
New control tokens per theme (buttonRadius, filledBorderWidth,
stateLayerOpacity, focusRingWidth, labelTracking, labelTransform), emitted
as CSS custom properties alongside the existing ones. This also gives
--label-weight its first consumer; it had been defined by every theme and
applied nowhere.
New primitives in src/components/ui/:
Button — variant primary/secondary/ghost/destructive, size sm/md/lg/icon,
themed icon by name, and `loading` which disables the button as
well as showing a spinner, since a double-submit is a real bug
and "disabled while pending" is the part call sites forget.
Input — plus Textarea; leading icon; 16px on the lg size so iOS does not
zoom the viewport on focus.
Badge — status pill carrying the theme's label treatment.
The default variant and sizes reproduce the previous class recipes exactly,
verified by a browser test asserting RapidTool's buttons still measure 12px
radius, weight 500, no border on a filled button and no state layer. Three
further tests pin Material's 9999px pill and 0.08 state layer, Fluent's 1px
filled stroke and 600 weight, and Graphite's uppercase.
DESIGN_SYSTEM.md replaces the button class-recipe table — which had been
copied into ~75 files — with the components, and explains why a copied class
string opts a control out of theming. The Tabs example and the typography
table were stale after earlier changes (icon names, theme fonts) and are
corrected. `cc-control` is documented for genuinely bespoke controls that
still want the theme's label treatment and focus ring.
Adoption starts with Settings → Appearance. The remaining ~1,100 buttons keep
rendering correctly from their existing classes and can migrate incrementally,
the same way the icon migration ran.
Verified: 359 unit tests, 18 browser tests, typecheck clean, build clean,
lint unchanged from baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Adopts the primitive across the app, so those controls now pick up the
theme's label weight, tracking, transform, state layer and focus ring —
Material's pills, Fluent's stroked solid buttons, Graphite's uppercase.
PROVING IT CHANGES NOTHING
--------------------------
This migration alters appearance if it gets anything wrong, so the codemod
audits itself: for every conversion it reconstructs the class list <Button>
will emit and diffs it against the original across display, alignment,
padding, gap, text size, radius and colour. It refuses to finish unless
every one is equivalent. All 308 are.
Screenshot diffing was tried first and abandoned as unusable here: these
pages render different navigation depending on whether the gateway answers,
so two runs of the SAME build differed by 14% on one page. That noise
buries exactly the regressions worth finding.
Three real infidelities were caught this way and fixed, none of which a
screenshot would have isolated:
• Size matching used hand-written class lists that had drifted from the
component, so a gapless button matched `md` and silently gained
`gap-1.5` between its icon and label. Sizes are now DERIVED from
Button's own table, which makes that drift impossible.
• Variant matching accepted a button whose classes merely CONTAINED the
variant's, with a permissive list of "optional" hovers. A button whose
hover was `hover:bg-secondary` was being given `hover:text-foreground
hover:border-primary/30` instead. Matching is now set equality.
• Button hardcoded `inline-flex items-center justify-center`, which
centred the content of a full-width, left-aligned menu item. Tailwind
cannot fix this from a class attribute — `justify-center` and
`justify-start` have equal specificity, so the stylesheet's order
decides. Layout is now a prop that REPLACES the default, and the
codemod reproduces the original's exactly, including the empty case (a
bare <button> is inline-block, not flex).
New Button API, all in service of migrating without changing anything:
variant += "text" (bare text action, no surface or border)
size += "icon-xs"/"icon-sm" (the app uses three icon sizes; collapsing
them would have resized ~100 controls) and "none" (caller
keeps its own bespoke geometry)
radius = "keep" preserves a non-theme radius verbatim rather than
silently resizing corners
layout = replaces display/alignment outright
The remaining 865 raw <button> elements are genuinely not variant buttons:
565 have colour signatures matching no variant (cards, tabs, tiles), 233
build their className conditionally from selected/active state, and the
rest are one-offs. Those keep rendering exactly as before.
Verified: 359 unit tests, 18 browser tests, typecheck clean, build clean,
lint unchanged from baseline.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
It sat in the app root next to package.json, and hardcoded an absolute path to this checkout, so it would not run for anyone else. The approach and its self-audit are recorded in the migration commit; re-deriving it is cheaper than maintaining a broken copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Closes the last two BO-10 items. Every async caller now resolves to a single engine and pool, and acb_audit.record() no longer blocks the loop. The seam lives in packages/acb_common/acb_common/db.py, not in gateway/db.py. acb_auth.access resolves a member's permissions from Postgres on the request path and runs inside the gateway process, but it cannot import gateway — so while the seam lived there the gateway had two pools no matter how many route packages were converted. acb_common is the one package both sides already depend on. gateway/db.py stays as a re-export because that is the import path the routes use. Converted the six remaining route packages (admin, apps, email, notes, whatsapp, workflows), joining tasks and crm, plus acb_auth.access. Each keeps its historical get_db / _get_db / _get_session_factory name as a re-export, so ~50 call sites and every monkeypatch.setattr(<sibling>, "_get_db", ...) in the suite are untouched. acb_auth's engine had never carried the connect-phase or idle-in-transaction bounds added after the 2026-08-06 outage; it inherits both now. Pool ceiling is 30 (db_pool_size 10 + db_max_overflow 20, now tunable), unchanged from the pre-consolidation seam and deliberately not the old ~165 sum: stock Postgres allows 100 and Langfuse, LiteLLM and the ingestion services share the server, so that sum was a budget that could not be spent, only exceeded. record() keeps its sync signature and dispatches to asyncio.to_thread only when called from a running event loop; sync callers still write inline, which is what the orchestrator's agents expect. acb_audit.drain() is awaited last in the gateway lifespan so shutdown cannot cancel an in-flight row — without it, non-blocking would have been a regression against the old behaviour where the write completed before the handler returned. Ratchet: tests/unit/test_db_engine_seam.py fails on a new create_async_engine call site (AST-parsed, not grepped — these modules mention the name in prose saying they do not call it) and separately fails when an allowlist entry stops creating an engine, so the list cannot rot into blanket permission. Plus tests/unit/test_audit_non_blocking.py. Left open by design and recorded in the allowlist: acb_graph/db.py's sync create_engine (different caller set; folding it in is an acb_graph rewrite) and email_ingestion's per-run engines (separate process, disposed when the run ends). Verified against a live Postgres 16 with all 148 migrations replayed: 4447 tests pass; nine consumers resolve to one engine object with real queries executed. The 2 remaining failures need a running gateway HTTP server and fail identically on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
The engine stopped at the iframe boundary. Custom Apps, generative-UI cards and React artifacts run in an opaque-origin sandbox that inherits nothing from us, so they were handed a `--cc-*` token block instead. The vocabulary was right — it is written into agent-app-builder's instructions and into design.md — but its VALUES were hand-written RapidTool literals switching only on light/dark. Every app ever built stayed RapidTool-blue while the shell around it turned Fluent or Material. Nothing errored; it just quietly did not theme. The contract now derives from the active manifest (lib/theme/app-tokens.ts), and the frame that carries it is its own React-free module (lib/theme/sandbox-frame.ts) so the e2e suite drives the real frame rather than a copy that could pass while the original was broken. What crosses the boundary now: colour with `-fg` ink pairs, type, shape, motion, and the theme's control PERSONALITY — Material's pill buttons and 8% state layer, Fluent's ring, Graphite's uppercase labels — so an app built a year ago picks up a new theme's behaviour, not merely its palette. Icons cross as SVG resolved from the active pack; they were pinned to Lucide. Applied twice: in the frame's first <style> so there is no flash, and as a postMessage patch on a theme change — a patch, not a rebuild, because rebuilding srcDoc remounts the document and a published app would throw away whatever the user had typed into it. Three bugs surfaced on the way, each silent by construction: * Font stacks embed `var(--font-geist-sans)`, a next/font handle that exists only on our <html>. An unresolvable var() invalidates the whole font-family, so apps had no themed font at all. Handles are stripped; named and system families cross. Self-hosted webfonts cannot — the frame's CSP is `font-src data:` — and that limit is now written down. * `controls.buttonRadius` is `var(--radius)`, likewise undefined in the frame, so themed buttons silently lost their radius. Resolved on export. * Ink on a warning fill was hardcoded near-black, legible only over a yellow warning. A theme with a dark warning rendered black on dark. Also token-ised genUITemplates and TodoPanel. The weather pictograms stay literal and are hoisted into one argued constant: a sun is yellow because suns are yellow, and recolouring it per theme is broken art, not themed art. Keeping it true is now enforced rather than asked for. `lib/theme/conformance.test.ts` fails the build on a hardcoded colour, a lucide-react import, an arbitrary Tailwind colour class, or a hand-rolled solid control. Existing debt is a frozen baseline that may only shrink: a file with no budget must be clean, a baselined file may not get worse, and one that got BETTER fails until its number is lowered — without that last rule the figures quietly become fiction. Values that are genuinely not theme decisions (Gmail's label palette, a person's identity hue, the pictograms) sit in an exceptions list with the argument for each, because the next author's real question is never "is this allowed" but "is mine like that one". Docs rewritten where agents actually read them: DESIGN_SYSTEM.md, control_plane/AGENTS.md, root AGENTS.md, agent-app-builder/instructions.md, and design.md — whose HSL table was being presented as a spec to copy from rather than one theme's values. A test checks the app-builder token list against the code in both directions, so a token that exists is always documented and a documented one always exists. playwright.config.ts gains an opt-in PLAYWRIGHT_EXECUTABLE_PATH; unset, it behaves exactly as before. Verified: 383 unit tests, 31 theming/sandbox/artifact e2e in a real browser (tokens survive the CSP, differ per theme, and a live patch restyles a running document without remounting it), clean tsc, clean build, lint unchanged from baseline. The 8 failing e2e in chat/email specs need a live gateway and fail identically without this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
BO-10: one async engine and pool per process + non-blocking audit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Brings BO-10 and the CRM + Projects apps onto the branch. Four things needed hand-resolution — none of them a textual conflict, which is why each was found by a test rather than by git. * **Migration number collision.** Both sides added a `145_`: main's `145_crm_zoho_sync.sql` and this branch's `145_org_settings.sql`. Two migrations with one number replay ambiguously. Renumbered ours to `147` (main also has `146_projects.sql`), with its three references. * **The conformance gate met code written before it existed.** The CRM and Projects apps landed while this branch was open and import `lucide-react` directly in 12 files, which pins those glyphs to Lucide on every theme — the one rule in the gate with no budget and no exceptions. Migrated them onto `<Icon name="…" />` with an AST codemod (regex cannot do this safely: `>` is also a comparison operator). Two files kept icons as component REFERENCES in lookup tables, which the codemod correctly declined to rewrite and reported instead. Converted those to name strings — the house convention — which also removed two `const Icon = …` bindings that would otherwise have shadowed the themed import inside the very functions using it. * **`Tabs` changed shape under the CRM page.** This branch made `TabDef.icon` a NAME so a tab bar cannot pin its icons to one pack; main's `/crm` passes a component. Textually clean, semantically broken — caught by `tsc`. * **A Python test parses the frame's stylesheet**, and this branch moved it out of `SandboxedHtml.tsx` into `lib/theme/sandbox-frame.ts`. Repointed `test_artifact_lint.py` and the two docstrings that name the old path. Two improvements the merge prompted: * **The solid-button detector was too loose.** `\bbg-secondary` also matched `hover:bg-secondary`, so a hover tint on a plain close button was reported as an un-migrated solid control. Added a lookbehind; the real count is 30, not 117 — the earlier Button migration was far more complete than the loose regex suggested. Migrated the 3 genuinely-solid buttons the new apps brought in, so the baseline lands at 30. * **8 icon names had no pack mapping** (`Kanban`, `IndianRupee`, `UserCheck`, `CircleAlert`, `Ban`, `Car`, `Flame`, `Siren`), so they rendered Lucide on Fluent and Material — graceful, but they would have been the only Lucide glyphs on the screen. Added mappings and rebuilt the packs; every icon name the app uses now resolves in all three. Verified: 520 frontend unit tests, 31 theming/sandbox/artifact e2e in a real browser, 4419 Python tests, clean tsc, clean build. The 5 remaining Python failures need a live gateway and fail identically on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
The theming engine: four themes switchable org-wide from Settings → Appearance, changing colour, type, shape, effects, icon pack and control personality across every page — and, as of this branch, across the sandboxed apps and generative UI that had been stuck on the default. A conformance gate keeps it true: hardcoded colour, a lucide-react import, an arbitrary Tailwind colour class or a hand-rolled solid control now fail the build, with a frozen baseline for existing debt that may only shrink. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
Thirteen commits landed on main while this was in flight (WS-27 task manager, the People app, ClickUp parity). Resolution strategy for every conflicted file: take THEIR version, then re-derive the mechanical icon migration on top — so the incoming feature work is preserved byte-for-byte and nothing is hand-merged into it. * Four `tasks/components/*` files were deleted by the incoming work and only icon-migrated by us; accepted the deletion. * Seven files still imported `lucide-react`, which pins those glyphs to Lucide on every theme. Re-ran the AST codemod. Three held icons as component REFERENCES in nav tables (`ListsSidebar`'s `NavRow.icon`, `tasks/page`'s `PanelToggle`) — converted to name strings, matching `nav.ts` and `Tabs`. That also removed three `Icon`/`const Icon =` bindings that shadowed the themed import inside the functions using it, which is the failure mode the codemod deliberately refuses to guess at. * One new solid `<button>` (`ConfirmationCard`'s Reject) moved to `<Button variant="secondary">`, keeping the ratchet at 30. * **Migration number, twice.** Ours had been renumbered 145 → 147 earlier in this merge; the incoming work then added `147_projects_personal.sql` and `150_projects_attachments.sql`. Now `151_org_settings.sql`. Caught both times by `test_migration_prefixes.py`, which is exactly the guard this needs — two migrations sharing a number replay ambiguously. Verified: 582 frontend unit tests, 29 theming/sandbox e2e in a real browser, 4568 Python tests, clean tsc, clean build, lint unchanged. The 5 remaining Python failures need a live gateway and fail identically on origin/main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyTtr9JJXCS8xNkzW5Fny5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two pieces of work, merged and verified together.
mainis protected, so this PR is the merge — the branch already containsorigin/mainmerged in, so it should go in clean.1. Theming engine
Four themes — RapidTool, Fluent, Material, Graphite — switchable org-wide from Settings → Appearance. They differ by far more than palette: corner radius, icon pack, glass/glow, and control behaviour (Material buttons are full pills with an 8% hover state layer; Graphite uppercases every button label).
A theme is a manifest, compiled to an
html[data-theme="…"]custom-property scope inlined once in the head, so switching is one attribute write — no fetch, no flash. Adding a theme is a manifest entry, not a component change.The part worth reviewing: the engine used to stop at the iframe boundary. Custom Apps, generative-UI cards and React artifacts run in an opaque-origin sandbox that inherits nothing from us, so they were handed a
--cc-*token block instead. That vocabulary was already right and already documented to agents — but its values were hand-written RapidTool literals switching only on light/dark. Every app ever built stayed RapidTool-blue while the shell around it turned Fluent or Material. Nothing errored; it just quietly did not theme.The block now derives from the active manifest. Colour (with
-fgink pairs), type, shape, motion, control personality and icons all cross. Applied twice: in the frame's first<style>so there's no flash, and as apostMessagepatch on a theme change — a patch, not a rebuild, because rebuildingsrcDocremounts the document and a published app would throw away whatever the user had typed.Three silent bugs surfaced on the way:
var(--font-geist-sans), anext/fonthandle that exists only on our<html>. An unresolvablevar()invalidates the wholefont-family, so sandboxed apps had no themed font.controls.buttonRadiusisvar(--radius), likewise undefined in the frame — themed buttons silently lost their radius.Staying themed is enforced, not asked for.
conformance.test.tsfails the build on a hardcoded colour, alucide-reactimport, an arbitrary Tailwind colour class, or a hand-rolled solid control. Existing debt is a frozen baseline that may only shrink — and a baselined file that gets better also fails until its number is lowered, without which the figures quietly become fiction. Values that genuinely aren't theme decisions (Gmail's label palette, a person's identity hue, weather pictograms) sit in an exceptions list with the argument for each.2. BO-10 — one async engine and pool per process
The gateway had twelve
create_async_enginecall sites whose ceilings summed to ~165 connections from one process, against a stock Postgres limit of 100 shared with Langfuse, LiteLLM and ingestion — a budget that could never be spent, only exceeded.The seam lives in
acb_common/db.py, not the gateway:acb_auth.accessresolves permissions from Postgres on the request path inside the gateway process and cannot importgateway, so a gateway-owned seam could never get below two pools. Its engine had also never carried the connect-phase andidle in transactionbounds added after the 2026-08-06 outage — it does now.acb_audit.record()keeps its sync signature and hands the write toasyncio.to_threadonly when a loop is running;drain()is awaited last in the gateway lifespan so shutdown can't cancel an in-flight row.db_pool_size/db_max_overflowwithout a code change.uv syncon deploy —acb_commongainedsqlalchemyandasyncpgas declared dependencies.Also: migration
151_org_settings.sqlneeds applying, or the org-wide theme default has nowhere to persist (personal choices work regardless).Merge resolutions
origin/mainmoved twice during this work (CRM, Projects, WS-27 task manager, People). Every conflicted file took their version, with the mechanical icon migration re-derived on top — so incoming feature work is preserved byte-for-byte. Notable:lucide-reactacross CRM, Projects, Tasks and People, migrated with an AST codemod. Several held icons as component references in nav tables; those became name strings, which also removed bindings that shadowed the themedIconimport.Tabs.iconchanged from a component to a name on this branch while/crmon main passed a component — textually clean, semantically broken, caught bytsc.lib/theme/sandbox-frame.ts. Repointed.test_migration_prefixes.py.Verification
582 frontend unit tests, 29 theming/sandbox e2e in a real browser, 4568 Python tests, clean
tsc, clean build, lint unchanged from baseline.The 5 remaining Python failures (
test_chat_features,test_phase0_zoho_reconciler) need a live gateway and database, and fail identically onorigin/main.Generated by Claude Code