Skip to content

Theming engine + BO-10 (one DB pool, non-blocking audit) - #379

Merged
vjvarada merged 14 commits into
mainfrom
claude/command-center-theming-engine-lnslje
Aug 7, 2026
Merged

Theming engine + BO-10 (one DB pool, non-blocking audit)#379
vjvarada merged 14 commits into
mainfrom
claude/command-center-theming-engine-lnslje

Conversation

@vjvarada

@vjvarada vjvarada commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Two pieces of work, merged and verified together. main is protected, so this PR is the merge — the branch already contains origin/main merged 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 -fg ink 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 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.

Three silent bugs surfaced on the way:

  • 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 sandboxed apps had no themed font.
  • controls.buttonRadius is var(--radius), likewise undefined in the frame — themed buttons silently lost their radius.
  • Ink on a warning fill was hardcoded near-black, legible only over a yellow warning.

Staying themed is enforced, not asked for. 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 — 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_engine call 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.access resolves permissions from Postgres on the request path inside the gateway process and cannot import gateway, so a gateway-owned seam could never get below two pools. Its engine had also never carried the connect-phase and idle in transaction bounds added after the 2026-08-06 outage — it does now.

acb_audit.record() keeps its sync signature and hands the write to asyncio.to_thread only when a loop is running; drain() is awaited last in the gateway lifespan so shutdown can't cancel an in-flight row.

⚠️ Two things to know before this deploys:

  1. The gateway's connection ceiling drops from ~165 to 30. That's the fix, but it's a real capacity change. Tunable via db_pool_size / db_max_overflow without a code change.
  2. uv sync on deployacb_common gained sqlalchemy and asyncpg as declared dependencies.

Also: migration 151_org_settings.sql needs applying, or the org-wide theme default has nowhere to persist (personal choices work regardless).

Merge resolutions

origin/main moved 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:

  • The conformance gate met code written before it existed: 19 files importing lucide-react across 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 themed Icon import.
  • Tabs.icon changed from a component to a name on this branch while /crm on main passed a component — textually clean, semantically broken, caught by tsc.
  • A Python test parses the frame's stylesheet, which moved to lib/theme/sandbox-frame.ts. Repointed.
  • The migration number collided three times as main advanced (145 → 147 → 150 → 151), each caught by 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 on origin/main.


Generated by Claude Code

claude added 14 commits August 6, 2026 05:24
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
@vjvarada
vjvarada merged commit 552d359 into main Aug 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants