diff --git a/AGENTS.md b/AGENTS.md index f898a3b98..c32de348d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -116,10 +116,28 @@ Copilot SDK sandboxes. - Tests in tests/unit/ and tests/integration/ -- pytest with asyncio - CI/CD via GitHub Actions: deploy.yml (push-to-deploy on main), pr-check.yml (lint+test on PRs) - Deploy target: Hostinger KVM 4 VPS (Ubuntu 24.04 + Docker) -- **Control Plane UI**: All frontend work MUST follow `workbench/control_plane/DESIGN_SYSTEM.md`. - Use shared components (`Tabs`, `FilterPills`, etc.) from `src/components/` — never inline - ad-hoc tab bars, filter pills, or page headers. Use semantic Tailwind color tokens - (`bg-primary`, `text-foreground`, `border-border`) — never arbitrary hex values. +- **Control Plane UI is THEMED. Read `workbench/control_plane/DESIGN_SYSTEM.md` + before writing any of it.** Settings → Appearance switches the whole org between + RapidTool, Fluent, Material and Graphite, which disagree about palette, corner + radius, icon pack, glass/glow and control behaviour (Material buttons are pills, + Graphite's labels are uppercase). Three rules, all machine-checked by + `src/lib/theme/conformance.test.ts`: + 1. **Never write a colour.** Use `bg-primary`, `text-foreground`, + `border-border`, `var(--success)` — not `#0ea5e9`, `hsl(…)` or `bg-[#1a1b1e]`. + Text on a coloured fill takes the `-foreground` partner, never `text-white`. + 2. **Never import `lucide-react`.** Use ``; Lucide names are + the vocabulary, the theme picks the pack. + 3. **Never hand-roll a control.** Use `Button`/`Input`/`Badge` from + `src/components/ui/` — a theme's state layer, focus ring and label transform + are not expressible in a class string, which is why the primitives exist. + Also use the shared `Tabs`, `FilterPills` and page-header patterns from + `src/components/` rather than inlining ad-hoc versions. +- **Apps that run in the sandbox** (Custom Apps, generative UI, React artifacts) + inherit nothing from the shell — they get the `--cc-*` contract instead + (`src/lib/theme/app-tokens.ts`, documented in + `apps/agents/agent-app-builder/instructions.md`). Style with those tokens and + the app follows the org's theme for life; write one hex value and that part of + it leaves the design system permanently. - Agent-generated artefacts (images, reports, PDFs) MUST be written to `inputs/`, `outputs/`, or `agent-data/` within the agent workspace so the Control Plane file browser and inline chat cards can discover them. These diff --git a/FOUNDATION_BUILDOUT_CHECKLIST.md b/FOUNDATION_BUILDOUT_CHECKLIST.md index 5428aab8c..9988a74d9 100644 --- a/FOUNDATION_BUILDOUT_CHECKLIST.md +++ b/FOUNDATION_BUILDOUT_CHECKLIST.md @@ -34,11 +34,12 @@ added**: |---|---|---|---| | 1 | ~~**`main` has no branch protection**~~ **CLOSED 2026‑08‑03** | §BO‑17 / `work_plan.md` WS‑5 | Was 404 / rulesets `[]` for months. **Now enabled:** PRs required (`required_approving_review_count: 0` — a sole maintainer must still be able to land work), `enforce_admins: true` (without it the protection is decorative for the only person who pushes), force‑pushes and deletions **blocked**. ⚠️ **`required_status_checks` is deliberately `null`** — `pr-check.yml` carries `paths-ignore: ["**.md", "ai-company-brain/**"]`, so a docs‑only PR runs **no** checks at all; requiring those contexts would leave every docs PR permanently unmergeable. To require them, first give `pr-check` an always‑runs sentinel job, then add that job as the only required context. | | 2 | **No *scheduled* backup; no restore ever exercised** | **§BO‑23** (below) | ◐ 2026‑08‑03: `backup_db.sh` + `restore_db.sh` + runbook shipped, and `apply_migrations.sh` now fails closed without a pre‑migration dump. **But nothing schedules it** (the systemd units need a `deploy/` write) and **no restore has been run**. Measured recovery position meanwhile: Hostinger VM images only, weekly, **2 retained, newest 5 days old**, ~58 min restore, whole‑machine granularity. **OWNER‑GATE** — install the timer, then run one restore. | -| 3 | **DB engine sprawl** | §BO‑10 | **12 `create_async_engine` call sites across 10 modules** (+ one sync engine), 8 of them undisposed process‑lifetime singletons. One arrived per app. **This is the only item whose cost compounds per app** — fix the seam before the next app, not after. | +| 3 | ~~**DB engine sprawl**~~ **CLOSED 2026‑08‑06** | §BO‑10 | Was **12 `create_async_engine` call sites across 10 modules** (+ one sync engine), 8 of them undisposed process‑lifetime singletons, one arriving per app — the only item whose cost compounded per app. **Now one engine and one pool for every async caller**, in `acb_common/db.py` (not the gateway: `acb_auth.access` runs in the gateway process and cannot import it). `acb_audit.record()` is non-blocking on the loop, drained at shutdown. A new engine now fails `tests/unit/test_db_engine_seam.py`. Remaining by design: `acb_graph`'s **sync** engine and `email_ingestion`'s per‑run engines. | Nothing else on this list needs to be closed first. Items 1 and 2 are risk -containment the owner must action; item 3 is the one an agent should fix before -the next app opens engine number 13. +containment the owner must action; item 3 was the one an agent should fix before +the next app opened engine number 13 — **closed 2026‑08‑06, and the seam is now +guarded by a test rather than by a note in this file.** --- @@ -177,8 +178,17 @@ was stale — corrected 2026-08-02 to match §BO‑20) were anonymous‑reachabl - **Dependencies:** Alembic; a one‑time baseline of the current schema (`schema.generated.sql` exists as a start). - **Approach:** Adopt Alembic (autogenerate baselined against `schema.generated.sql`), run it in `lifespan`/entrypoint, keep the raw files as historical. Add a CI check for unique numeric prefixes until then. -### BO‑10 — Consolidate DB access to one engine/pool *(P2)* ◑ -- **Done (Session 2, 2026‑07‑13):** **every** engine now bounds the CONNECT phase so a slow/unreachable DB can't hang callers — `settings.db_connect_timeout` (default 10s) on `acb_graph.get_engine()` (`ccccdc8`, live in prod), the two gateway asyncpg engines (`1684e1a`), and the four `email_ingestion` async engines (`1ff6c0d`, local, unpushed) via `connect_args={"timeout": …}`. This makes `acb_audit.record()`'s "never block the caller" guarantee real against a hung connect. Test: `tests/unit/test_db_connect_timeout.py`. +### BO‑10 — Consolidate DB access to one engine/pool *(P2)* ✅ **CLOSED 2026‑08‑06** +- **Closed (2026‑08‑06).** Every async caller now resolves to ONE engine and ONE pool, and `acb_audit.record()` no longer blocks the loop. What actually shipped, and the two places it departs from the "Approach" line written below in July: + - **The seam lives in `packages/acb_common/acb_common/db.py`, not in `acb_graph`.** `acb_graph` was the wrong home twice over: the gateway does not depend on it (nominating it would drag pgvector/AGE into a process that needs neither), and its own engine is **sync**. `acb_common` is the one package every service and every `acb_*` library already imports. `gateway/db.py` remains as a re-export because that is the import path the route packages use. + - **`acb_auth.access` is why the seam had to leave the gateway.** It resolves a member's permissions from Postgres *on the request path*, runs inside the gateway process, and cannot import `gateway`. While the seam lived in `gateway/db.py` the gateway had two pools no matter how many route packages were converted — so "one engine" was unreachable by converting routes alone. Its engine had also never carried the connect-phase or `idle in transaction` bounds added after the 2026‑08‑06 outage; it inherits both now. + - **Converted:** the six remaining route packages (`admin`, `apps`, `email`, `notes`, `whatsapp`, `workflows`) joining `tasks` and `crm`, plus `acb_auth.access`. Each kept its historical `get_db` / `_get_db` / `_get_session_factory` name as a re-export, so ~50 call sites and every `monkeypatch.setattr(, "_get_db", …)` in the test suite are untouched. Verified live: nine consumers, one engine object, real queries. + - **Pool ceiling: 30** (`settings.db_pool_size` 10 + `db_max_overflow` 20, both now tunable), unchanged from the pre-consolidation seam. Deliberately *not* raised to the old sum: the twelve pools summed to ~165 connections from one process against a stock `max_connections` of 100 that Langfuse, LiteLLM and the ingestion services also draw from — a budget that could not be spent, only exceeded. + - **`dispose()` in the lifespan was considered and rejected**, contradicting the July approach line. The pool's lifetime *is* the process's; a dispose seam is a way to close connections other in-flight handlers are still using. BO‑9's "nothing disposes anything" observation is correct about the fact and wrong about the remedy for this engine. + - **`acb_audit.record()`** keeps its sync signature (25-odd call sites, most of them sync) and dispatches the write to `asyncio.to_thread` **only when called from a running event loop**; sync callers still write inline, which is what they 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 the build on a new `create_async_engine` call site (AST-parsed, not grepped — every one of these modules mentions the name in prose saying it does *not* call it) and separately fails when an allowlist entry stops creating an engine, so the allowlist cannot rot into blanket permission. Plus `tests/unit/test_audit_non_blocking.py` (loop not blocked, write genuinely off-thread, sync callers still inline, drain waits, drain is bounded). +- **Still open, deliberately:** `packages/acb_graph/acb_graph/db.py:32`'s **sync** `create_engine`. It serves a different (sync) caller set — including `acb_audit`'s own write — and folding it in is an `acb_graph` rewrite, not this ticket. The four `email_ingestion` engines also stay: separate process, per-run lifetime, disposed when the run ends. Both are recorded in the test's allowlist with those reasons. +- **Done earlier (Session 2, 2026‑07‑13):** **every** engine now bounds the CONNECT phase so a slow/unreachable DB can't hang callers — `settings.db_connect_timeout` (default 10s) on `acb_graph.get_engine()` (`ccccdc8`, live in prod), the two gateway asyncpg engines (`1684e1a`), and the four `email_ingestion` async engines (`1ff6c0d`, local, unpushed) via `connect_args={"timeout": …}`. This makes `acb_audit.record()`'s "never block the caller" guarantee real against a hung connect. Test: `tests/unit/test_db_connect_timeout.py`. - **Missing — the "three+" above was written in July and is now materially wrong; re‑measured 2026‑08‑03.** It is **12 `create_async_engine(...)` call sites across 10 modules**, plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`: | Module | Sites | Shape | |---|---|---| @@ -194,8 +204,8 @@ was stale — corrected 2026-08-02 to match §BO‑20) were anonymous‑reachabl | `email_ingestion/scheduler.py:142, 527, 560` | 3 | per‑call, disposed at `:424`/`:540`/`:590` | | `packages/acb_graph/acb_graph/db.py:32` | (1 sync) | `create_engine`, a different flavour again | **The eight cached singletons are never disposed** — repo‑wide, the only `engine.dispose()` calls are the four `email_ingestion` per‑call engines cleaning up after themselves, and nothing in the gateway lifespan disposes anything (BO‑9). Also still open: sync `acb_audit.record()` blocks the async loop (H11) — connect_timeout bounds the hang but the call is still synchronous. + *(The table above is the 2026‑08‑03 measurement, kept as the record of what was found. Every async row in it is closed as of 2026‑08‑06 — see the top of this section for what replaced them and why the July approach line was not followed literally.)* - **Why it moved up the list:** the count grew by *one engine per app* — `notes`, `whatsapp`, `workflows` and `apps` all arrived with their own. This is the only foundation item whose cost **compounds per app**, so it is the one to fix before the next app rather than after (see `work_plan.md` §2's "Can we go app by app?" block, exception 3). -- **Approach:** Provide a single configured async engine in `acb_graph` (sized pool), funnel all callers through it, dispose it in the gateway lifespan, and make `acb_audit.record()` async (or always call via `to_thread`). ### BO‑11 — Decide `acb_schemas`: wire in or delete *(P2)* ✅ - **Done:** deleted the package (0 production importers, drifted from the ORM — H10). Removed its 7 `pyproject` dependency declarations + `tool.uv.sources` entry, the smoke‑test import, and the stale "wire/API surface" comment in `acb_graph/models.py`; re‑locked. Bonus: this exposed a latent under‑declared dependency — `orchestrator/triage/schema.py` uses pydantic `EmailStr` (needs `email‑validator`) but only got it transitively via `acb_schemas`; now declared explicitly as `pydantic[email]` on the orchestrator. @@ -1674,7 +1684,7 @@ non‑blocking style backlog. 1. **P0 hardening sprint (do first):** **BO‑23 (backup/restore — scripts + runbook are AGENT‑SAFE; it is P0 because it is the only unrecoverable failure mode here)**, BO‑8 (rotate+purge secrets), BO‑2 (auth enforcement — ✅ since), BO‑1 (Action Broker), BO‑3 (mutation governance). These close the Critical trust‑boundary and governance gaps that everything else sits on. 2. **P1 sprint:** BO‑7 (sandbox), BO‑5 (observability+cost), BO‑6 (migrations), BO‑12/BO‑14 (runtime + permission model), BO‑15 (LLM config SoT — **tier→model half only**), BO‑17/BO‑18 (gates), **BO‑20 (event‑bus consumer + job queue)**. *(BO‑19 closed 2026‑08‑03.)* -3. **P2/P3:** BO‑9, **BO‑10 (promoted in practice — it is the one item that compounds per app; see the verdict block at the top)**, BO‑11, BO‑13, BO‑16, **BO‑21 (memory activation)**. +3. **P2/P3:** BO‑9, ~~BO‑10~~ **(✅ closed 2026‑08‑06 — one engine/pool + non-blocking audit; it was the one item that compounded per app)**, BO‑11, BO‑13, BO‑16, **BO‑21 (memory activation)**. **Competitive‑informed items** (proven reference implementations from Hermes Agent / OpenClaw — full mapping in `ai-company-brain/specs/competitive_hardening_2026-07.md`): CH‑1→BO‑7/BO‑14, CH‑2→BO‑1, CH‑3→BO‑20, CH‑4→WBS 3.3, CH‑5→BO‑12, CH‑6→BO‑21, CH‑7→Phase‑5 Annealer, CH‑8→BO‑5. These do not change the sequencing above — they attach a "what good looks like" reference to items we already have, plus the two new items (BO‑20/BO‑21) the comparison surfaced. diff --git a/FOUNDATION_CONTINUATION.md b/FOUNDATION_CONTINUATION.md index a9cd31d60..489774f35 100644 --- a/FOUNDATION_CONTINUATION.md +++ b/FOUNDATION_CONTINUATION.md @@ -51,7 +51,7 @@ Non-negotiable #4 went from *false* → *enforceable on demand*: ### 4. The larger P1/P2 (buildable autonomously, no owner gate — good next-session work) - **BO-7** sandbox dynamic agent execution (security-critical, big — needs a substrate call: reuse the mutation container vs nsjail). - **BO-6** Alembic + auto-apply (start with the `schema_migrations` ledger, the smallest safe win). -- **BO-10 rest:** consolidate to ONE shared async engine + make `acb_audit.record()` non-blocking (`to_thread`). +- ~~**BO-10 rest:** consolidate to ONE shared async engine + make `acb_audit.record()` non-blocking (`to_thread`).~~ **DONE 2026-08-06** — see the BO-10 row in the table below. - **BO-13 finish:** decompose `run_agent_stream` (~1,600 lines) behind a `Runtime` interface — extend the harness to the **Copilot tier + idle-timeout** branches FIRST (can only be exercised on Linux/CI, not the Windows dev box — see the caveat below). - **BO-15** tier→model single source of truth (then **BO-16** can retire the now-load-bearing `config.yaml`), **BO-5** OTLP export, **BO-9** lifecycle, **BO-17** graduate CI gates + broaden eval paths, **BO-14** destructive-tool registry. @@ -117,7 +117,7 @@ uv run python -m pytest -m integration -q # NEW: needs the docker s **DB connection facts:** - Settings key: `database_url` (`packages/acb_common/acb_common/settings.py`), default `postgresql+psycopg://acb:acb_dev_change_me@localhost:5432/acb`. -- Access layer: `acb_graph.get_session()` (sync SQLAlchemy). Async engines also exist in `routes/tasks/core.py` and `routes/email/core.py` (see BO-10). +- Access layer: `acb_graph.get_session()` (sync SQLAlchemy) for sync callers. **Every async caller shares ONE engine and pool: `acb_common.db` (`get_db`/`get_session_factory`), re-exported as `gateway.db`** — BO-10, closed 2026-08-06. Adding a second `create_async_engine` fails `tests/unit/test_db_engine_seam.py`. - Migrations dir: `infra/postgres/NN_*.sql` (idempotent by hand); runner: `scripts/apply_migrations.sh`; applied on deploy by `deploy/hostinger/deploy.sh:71` and `.github/workflows/deploy.yml:163`. --- @@ -223,7 +223,7 @@ Two runtimes coexist (native MAF + GitHub Copilot SDK) while `AGENTS.md` claims | **BO-4** event bus | Redis Streams producer (`apps/services/ingestion/ingestion/queue.py`) has **no consumer** — no `xreadgroup`/`xack` anywhere in the repo, so `ingestion:*` and `ingestion:dlq` are write-only and trimmed unread. **Correction (2026-08-02): "webhook→agent flow not wired" is stale** — ClickUp fans out through `ingestion.event_hooks` to `workflows.triggers.dispatch_event` → `start_run` today (commit `e20ea830`); Gmail/Zoho receivers are still TODO stubs. | `apps/services/ingestion/` | **Owner: `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-20 (= BO-4). This row is a pointer, not a second plan** — the earlier "ship it OR drop the claim" fork is withdrawn; §BO-20 owns the scope, the tickets (BO-20a–f), the acceptance criteria and the one open owner decision (§BO-20.0, the process model). Redis is already provisioned. | | **BO-5** observability | OTel disabled + exporter not installed + no collector | `acb_common` deps, `infra/docker-compose.yml`, `executor` kill-switch | Either add `opentelemetry-exporter-otlp` + a collector (Langfuse half-present under `obs` profile) and re-enable MAF/LiteLLM tracing, or delete the OTel deps + "OTLP-ready" claim. | | **BO-9** lifecycle | Fire-and-forget `ensure_future` warmups untracked/never cancelled; no engine/Neo4j dispose on shutdown; Redis per-call in ingestion | `apps/gateway/gateway/main.py`, `ingestion/queue.py` | Hold task refs + cancel after `yield`; create/dispose a shared engine + Redis pool in `lifespan`. | -| **BO-10** DB engines ◑ | **Partial:** `connect_timeout` now on **every** engine — `acb_graph` (ccccdc8), the two gateway engines (1684e1a), and the four `email_ingestion` engines (`1ff6c0d`, local). **Left:** consolidate to ONE shared async engine; **make sync `acb_audit.record()` non-blocking** (still blocks the loop on async paths). | `acb_graph/db.py`, `routes/tasks/core.py`, `routes/email/core.py`, `email_ingestion/*`, `acb_audit/log.py` | Provide one configured async engine in `acb_graph`; funnel all callers; make `record()` async (or always `to_thread`). | +| **BO-10** DB engines ✅ | **CLOSED 2026-08-06.** `connect_timeout` was already on every engine (`ccccdc8`/`1684e1a`/`1ff6c0d`). Both remaining items are now done. **(a) ONE async engine.** The seam lives in `acb_common/db.py` — not in `gateway/db.py`, because `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. `gateway/db.py` is a re-export. All eight gateway route packages plus `acb_auth.access` resolve to it; each kept its historical `get_db`/`_get_db`/`_get_session_factory` name as a re-export so ~50 call sites and every `monkeypatch.setattr(, "_get_db", …)` are untouched. **acb_auth's engine had never carried the 2026-08-06 connect/`idle in transaction` bounds — it does now.** Pool ceiling 30 (`db_pool_size`+`db_max_overflow`, tunable), against ~165 before: stock Postgres allows 100 and Langfuse/LiteLLM/ingestion share it, so the old sum was a budget that could not be spent, only exceeded. **(b) Non-blocking audit.** `record()` keeps its sync signature and dispatches to `asyncio.to_thread` only when called from a running loop; sync callers still write inline. `acb_audit.drain()` is awaited last in the gateway lifespan so a shutdown cannot cancel an in-flight row. Ratchet: `tests/unit/test_db_engine_seam.py` (AST-parsed, allowlisted, fails on a new engine AND on a stale allowlist entry) + `tests/unit/test_audit_non_blocking.py`. **Still open, deliberately:** `acb_graph/db.py`'s **sync** `create_engine` — a different pool for a different (sync) caller set, and converting it is an acb_graph rewrite, not this ticket. | `acb_common/db.py` (new), `gateway/db.py`, `routes/{admin,apps,email,notes,whatsapp,workflows}/*`, `acb_auth/access.py`, `acb_audit/log.py`, `gateway/main.py` | — | | **BO-15** LLM SoT | tier→model still defined in 4 disagreeing places; `_TIER_CONTEXT_WINDOWS` a stale copy | `acb_llm/client.py`, `infra/litellm/*.yaml`, `settings.py`, DB `model_config` | Make DB `model_config` authoritative; delete `tier_overrides.yaml`/`enabled_models.json`/proxy directives once seeded; have `settings.py` read windows from `context.py`. | | **BO-16** vestigial proxy | `infra/litellm/config.yaml` is a full proxy config but no proxy runs; `provider_models_cache.json` a rotting committed cache | `infra/litellm/`, `infra/provider_models_cache.json` | Reduce to the tier map (or DB); delete the cache; align `infra/AGENTS.md`. | | **BO-7** sandbox | Dynamic agent code runs in-process w/ full gateway privileges; deps install into shared venv | `acb_skills/loader.py` | Run agent execution in the mutation-style container / restricted subprocess w/ per-run venv. Big; security-critical. | @@ -238,7 +238,7 @@ Two runtimes coexist (native MAF + GitHub Copilot SDK) while `AGENTS.md` claims The foundation is "production-ready" (the audit's bar) when: 1. **Trust boundaries enforced:** default-deny auth (B2); Action Broker gates all outward writes (A2/B1); dynamic agent code sandboxed (BO-7); leaked secret rotated + purged from history (BO-8 residual). 2. **Governance real:** self-mutation counter + human gate + real test gate — **done** (BO-3/F8); mutation wired into the streaming path or explicitly scoped (H5). -3. **Data layer sound:** Alembic + auto-apply (A1); one DB engine + non-blocking audit (BO-10). +3. **Data layer sound:** Alembic + auto-apply (A1); one DB engine + non-blocking audit — **done** (BO-10, 2026-08-06). 4. **Observability honest:** either real OTLP export + collector, or the claim removed; cost per-tier populated — **partly done** (BO-5). 5. **Event flow wired:** the webhook→run path is real for ClickUp already (via the event-sink registry, not Redis); the bar here is the durable half — a consumer draining `ingestion:*` with retry/DLQ, and Gmail/Zoho receivers at ClickUp parity. Owned by `FOUNDATION_BUILDOUT_CHECKLIST.md` §BO-20 (= BO-4); the "or remove the claim" alternative is withdrawn. 6. **Executor maintainable:** `run_agent_stream` decomposed behind a `Runtime` interface, xenon ceiling ratcheted (C1). diff --git a/ai-company-brain/work_plan.md b/ai-company-brain/work_plan.md index fba68a184..2e3a4b432 100644 --- a/ai-company-brain/work_plan.md +++ b/ai-company-brain/work_plan.md @@ -99,7 +99,7 @@ looks. Full statements live in `FOUNDATION_BUILDOUT_CHECKLIST.md`. |---|---|---|---| | 1 | ~~**`main` has no branch protection**~~ — **CLOSED 2026-08-03** | WS-5 · checklist §BO-17 | Was `404 Branch not protected` with rulesets `[]` under both mechanisms, so every CI gate in the YAMLs was decorative. **Enabled 2026-08-03** (owner-authorised in-session): PRs required, `required_approving_review_count: 0`, **`enforce_admins: true`**, force-push and deletion blocked. Verified by reading the protection back. ⚠️ **`required_status_checks` is deliberately `null`**: `pr-check.yml` has `paths-ignore: ["**.md", "ai-company-brain/**"]`, so a docs-only PR produces **no** check-runs — requiring those contexts would make every docs PR permanently unmergeable (this row's own PR included). Tightening path: add an always-runs sentinel job to `pr-check`, then require **that** one context. | | 2 | **No backup / restore path** | **new: checklist §BO-23** | The only DB script that dumps anything is `scripts/dump_schema.sh`, which is `pg_dump --schema-only` (`:52`) — **structure, zero rows**. There is no `pg_restore`, no logical data dump, no WAL archiving (`archive_mode`/`wal_level`/`pgbackrest`/`wal-g` appear nowhere in `infra/` or `deploy/`), and no restore runbook. Meanwhile `scripts/apply_migrations.sh` replays **every** numbered migration ≥ `02_` on **every** deploy under `psql -v ON_ERROR_STOP=1` (`:59-74`) with no ledger and no down-migrations — 140 files today, 142 numbered files on disk. `deploy/hostinger/README.md:115` is honest that the only backup is Hostinger's **weekly whole-VPS** image and that PITR is a "later" item. Largest uncovered risk, and it scales with app count. | -| 3 | **DB engine sprawl** | checklist §BO-10 | Measured 2026-08-03: **12 `create_async_engine(...)` call sites across 10 modules** (`acb_auth/access.py:69`; gateway `routes/{admin,apps,email,notes,tasks,whatsapp,workflows}/*core*.py`; `email_ingestion/{inbound,scheduler}.py` ×4), plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`. Eight are module-level cached `_ENGINE` singletons and **none of them is disposed on shutdown** — the only `engine.dispose()` calls in the tree are the four `email_ingestion` per-call engines cleaning up after themselves. **This is the one that compounds: one engine per app, added by each app.** The next app should extend a shared seam, not add engine 13. | +| 3 | ~~**DB engine sprawl**~~ **CLOSED 2026-08-06** | checklist §BO-10 | Measured 2026-08-03: **12 `create_async_engine(...)` call sites across 10 modules** (`acb_auth/access.py:69`; gateway `routes/{admin,apps,email,notes,tasks,whatsapp,workflows}/*core*.py`; `email_ingestion/{inbound,scheduler}.py` ×4), plus a 13th **sync** `create_engine` in `acb_graph/db.py:32`. Eight are module-level cached `_ENGINE` singletons and **none of them is disposed on shutdown** — the only `engine.dispose()` calls in the tree are the four `email_ingestion` per-call engines cleaning up after themselves. **This is the one that compounds: one engine per app, added by each app.** The next app should extend a shared seam, not add engine 13. **CLOSED 2026-08-06:** every async caller now resolves to ONE engine and pool in `packages/acb_common/acb_common/db.py` — not `acb_graph` (the gateway does not depend on it, and its engine is sync) and not `gateway/db.py` (which `acb_auth/access.py` cannot import, so a gateway-owned seam could never get below two pools in the gateway process). The six remaining route packages plus `acb_auth.access` were converted, each keeping its historical `get_db`/`_get_db`/`_get_session_factory` name as a re-export so ~50 call sites and every test monkeypatch are untouched; `gateway/db.py` is a re-export. `acb_auth`'s engine had never carried the 2026-08-06 connect/`idle in transaction` bounds — it does now. Pool ceiling 30 (tunable via `db_pool_size`/`db_max_overflow`), deliberately not the old ~165 sum, which exceeded a stock `max_connections` of 100 shared with Langfuse/LiteLLM/ingestion. `acb_audit.record()` is non-blocking on the loop (`to_thread` only when a loop is running; sync callers still inline) and `acb_audit.drain()` is awaited last in the gateway lifespan. Guarded by `tests/unit/test_db_engine_seam.py` + `tests/unit/test_audit_non_blocking.py`. Still open by design: `acb_graph/db.py`'s **sync** `create_engine` and `email_ingestion`'s per-run engines. | ### Substrate (foundation) diff --git a/apps/agents/agent-app-builder/instructions.md b/apps/agents/agent-app-builder/instructions.md index 0602fbd09..1e0271c35 100644 --- a/apps/agents/agent-app-builder/instructions.md +++ b/apps/agents/agent-app-builder/instructions.md @@ -116,14 +116,53 @@ single line of CSS. Reach for these instead of hand-rolling equivalent styles; it's both the fastest path (no CSS to write or debug) and the only way the app is guaranteed to look native, not "close enough." -**Never redeclare these** (no `:root { --primary: ... }`, no `.cc-card { ... }` -override) — use them as-is, the same rule `load_design_system`'s full reference -states for reports. - -- **Tokens** (use directly in your own CSS, never redefine): `--cc-bg`, - `--cc-card`, `--cc-fg`, `--cc-muted`, `--cc-border`, `--cc-secondary`, - `--cc-primary`, `--cc-primary-fg`, `--cc-accent`, `--cc-success`, - `--cc-warning`, `--cc-danger`, `--cc-radius`, `--cc-ease`. +### The one rule that matters: never write a colour + +**CommandCenter is themed, and the theme is an org-wide setting somebody can +change at any time.** RapidTool, Fluent, Material and Graphite differ in palette +*and* in personality — corner radius, button shape, icon set, whether labels are +uppercase, how a control reacts to hover. Settings → Appearance switches all of +it, for everyone, in one click. + +Every `--cc-*` value below is resolved from **whatever theme is active when your +app is opened**, and updates live if it changes while the app is running. So an +app styled with tokens follows the company's design system for the rest of its +life, with no edit. An app with `background: #0ea5e9` in it is stuck looking like +2026's theme forever, on a surface where everything around it has moved on — and +because it *renders fine*, nobody will notice until it looks broken. + +That is the whole deal: **use the tokens and you get theming for free; write one +hex value and that part of your app leaves the design system permanently.** + +**Never redeclare a token** (no `:root { --cc-primary: ... }`, no +`.cc-card { ... }` override) — a redeclaration is a hardcoded colour with extra +steps, and it wins over the theme. + +- **Colour** — `--cc-bg` (page), `--cc-card` (panel), `--cc-fg` (text), + `--cc-muted` (secondary text), `--cc-border`, `--cc-secondary` (quiet fills), + `--cc-primary` + `--cc-primary-fg` (the interactive colour and the ink that + goes ON it), `--cc-accent`, and the four states `--cc-success`, + `--cc-warning`, `--cc-danger` each with an ink pair `--cc-success-fg`, + `--cc-warning-fg`, `--cc-danger-fg`. + **Always use the `-fg` partner for text on a coloured fill.** White is not + safe: some themes ship a pale warning colour, and white-on-pale is invisible. +- **Type** — `--cc-font` (UI stack), `--cc-mono` (figures, code, anything that + should line up in columns), `--cc-heading-weight`, `--cc-heading-tracking`. + *Caveat worth knowing:* the frame can only pass **named and system** families, + so you get Segoe UI on Fluent and Roboto on Material, but not a self-hosted + webfont — the sandbox blocks font loading by design. Shape, spacing and colour + cross intact; that one thing does not. +- **Shape & motion** — `--cc-radius`, `--cc-border-width`, `--cc-shadow`, + `--cc-duration`, `--cc-ease`. Use `--cc-ease` and `--cc-duration` for + transitions rather than inventing curves; it is how the app feels the same as + the shell around it. +- **Control personality** — `--cc-button-radius` (Material makes buttons full + pills, Fluent nearly square), `--cc-control-filled-border`, + `--cc-control-state-layer`, `--cc-control-focus-ring`, + `--cc-control-label-tracking`, `--cc-control-label-transform`. + You mostly won't touch these: `.cc-btn` and the native controls already apply + them. They are here for when you build a control the kit doesn't have, so it + can behave like the ones it does. - **Buttons & panels** — `` / ``, `
`. Native `input`/`select`/`textarea`/`input[type=range]` are already styled diff --git a/apps/services/gateway/AGENTS.md b/apps/services/gateway/AGENTS.md index 2555c0d0a..f9a42fb15 100644 --- a/apps/services/gateway/AGENTS.md +++ b/apps/services/gateway/AGENTS.md @@ -13,7 +13,7 @@ webhook receivers, OAuth callbacks, and the Control Plane API. ## Local Contracts -0. db.py -- **The shared async engine seam (BO-10).** `get_engine()` / `get_session_factory()` / `get_db()`, module-level cached, SQLAlchemy's async stack imported INSIDE the functions so importing this module does not drag it into a process that never opens a connection. The engine is never disposed here: the pool's lifetime is the process's, and a `dispose()` seam would be a way to close connections other handlers are still using. **A new app package consumes this rather than adding engine thirteen** — `routes/crm` does, and `routes/tasks/core.py` was converted to it as the proof. See item 12c. +0. db.py -- **The shared async engine seam (BO-10) — now a re-export of `acb_common.db`, which is where the seam and its reasoning live.** `get_engine()` / `get_session_factory()` / `get_db()`, module-level cached, SQLAlchemy's async stack imported INSIDE the functions so importing it does not drag it into a process that never opens a connection. The engine is never disposed: the pool's lifetime is the process's, and a `dispose()` seam would be a way to close connections other handlers are still using. **The gateway makes ZERO `create_async_engine` calls of its own** — closed 2026-08-06 by converting the last six route packages (`admin`, `apps`, `email`, `notes`, `whatsapp`, `workflows`), each of which kept its historical `get_db`/`_get_db`/`_get_session_factory` names as re-exports so ~50 call sites and every `monkeypatch.setattr(, "_get_db", …)` in the tests are untouched. Why the seam moved OUT of the gateway: `acb_auth.access` resolves permissions from Postgres on the request path and runs *inside* this process but cannot import `gateway`, so while the seam lived here the gateway had two pools no matter how many route packages were converted — and that second engine also never carried the 2026-08-06 connect/`idle in transaction` bounds. `acb_common` is the one package both sides already depend on. Pool sizing is `db_pool_size`/`db_max_overflow` (default 10+20 = 30, unchanged from the pre-consolidation seam) because the arithmetic is deployment-wide: stock Postgres allows 100 and Langfuse/LiteLLM/ingestion share the server — the twelve old pools summed to ~165, a budget that could not be spent, only exceeded. **A new app package consumes this rather than adding an engine**, and `tests/unit/test_db_engine_seam.py` fails the build if one appears (AST-parsed, not grepped, with an allowlist that must not go stale). See item 12c. 1. main.py -- FastAPI app factory, lifespan (key loading, model cache warmup, aiosmtpd inbound SMTP startup, background email sync scheduler, and the seven supervised loops — email sync, WhatsApp enrichment, tasks provider-sync, calendar auto-rollover, workflow schedule scanner, the BO-20a ingestion event-bus consumer `ingestion.consumer.start_ingestion_consumer` (flag-gated OFF on `INGESTION_CONSUMER`), and the CRM⟷Zoho two-way sync `routes.crm.sync_zoho.start_crm_zoho_sync` (flag-gated OFF on `CRM_ZOHO_SYNC`, and the gate lives inside that function, not here); **every one of the seven is stopped unconditionally on shutdown**, including the flag-gated ones whose loop may never have started — for the ingestion consumer that contract is pinned by `tests/unit/test_ingestion_consumer.py::test_gateway_lifespan_starts_the_consumer_and_stops_it_after_yield`, which reads `main.py` as text and asserts start-before-`yield`, stop-after, and no flag guard on the stop), /copilot/chat AG-UI endpoint (relayed through stream_relay.run_detached when thread_id present) 2. routes/agent.py -- /agent/run, /agent/run/stream (detached: agent survives client disconnect), /agent/run/{thread_id}/reconnect (replay + live follow), /agent/webhook, agent CRUD, mutation inbox (approve/reject). Org access control: `GET /agent` filters the registry to agents the caller may run (unless they hold `agents:manage`, who see everything); all THREE run endpoints (`/run`, `/run/stream`, `/run/async`) call `assert_can_run_agent` — the filtered picker is UX, the endpoint check is the boundary of record. Registry writes (register/patch/delete/pull) need `agents:manage`. `/agent/webhook/{source}` is the fourth run path and is authenticated by HMAC-SHA256 over the raw body (`X-CC-Signature`; per-source `AGENT_WEBHOOK_SECRET_` overrides the global one) — it FAILS CLOSED with 503 when no secret is configured, because it starts an agent run and is internet-reachable. The same events also fan out to workflow event triggers (`routes/workflows/triggers.dispatch_event`, best-effort) — an event can route to an agent, to workflows, or both 3. routes/chat.py -- Chat history CRUD (Postgres-backed sessions and messages) + GET /chat/active-sessions (Redis cc:active:* scan for running agents). Authorization is MEMBERSHIP, not ownership: every predicate that used to be `WHERE user_id = :uid` now goes through `gateway/rooms.py` (`SESSION_VISIBLE_SQL` for lists, `resolve_room_access` for a single session), which is the same answer when a session has one participant. `_get_messages` also applies two room rules that are no-ops outside a room — the late-joiner waterline (`chat_session_participant.join_message_ts`) and the clearance filter (`_render_message` redacts a turn produced by a run acting on capabilities the reader does not hold). `_upsert_messages` stamps authorship SET-ONCE: the three racing writers cannot rename an author, and a human turn is always attributed to the authenticated caller, never to a client-supplied identity. diff --git a/apps/services/gateway/gateway/db.py b/apps/services/gateway/gateway/db.py index 86be70d50..85f9444a1 100644 --- a/apps/services/gateway/gateway/db.py +++ b/apps/services/gateway/gateway/db.py @@ -1,126 +1,36 @@ """The gateway's shared async database seam (BO-10). -The gateway had **twelve** module-level ``create_async_engine`` call sites, one -per app package, each with its own pool that nobody can size, drain or observe -as a whole. The board's standing instruction is that the next app extends a -shared seam rather than adding engine thirteen — so this module exists and -``routes/crm`` consumes only it. - -This is the ``routes/tasks/core.py`` block **lifted, not redesigned**: the same -URL coercion, the same pool sizing, the same bounded connect phase. Converting a -consumer to it is therefore a no-op at runtime, which is the property that makes -``routes/tasks/core.py`` usable as the proof that the seam works (D-CRM-4, -``ai-company-brain/specs/crm_app.md`` §4). Converting the other ten call sites -is explicitly out of scope there and is its own chore. - -Two rules worth keeping: - -* ``sqlalchemy.ext.asyncio`` is imported **inside** the functions, not at module - scope. Every app package does it that way and the gateway's import graph - depends on it — importing this module must not drag SQLAlchemy's async stack - into a process that never opens a connection. -* The engine is created on first use and cached for the process. It is never - disposed here: the pool's lifetime is the process's, and a ``dispose()`` seam - would be a way to close connections other request handlers are still using. +This module is now a **re-export of** :mod:`acb_common.db`, which is where the +seam and all of its reasoning live. It stays here because it is the import path +every gateway route package already uses, and because "the gateway's DB seam" is +the name people look for. + +Why the seam moved out of the gateway: ``acb_auth.access`` resolves a member's +permissions from Postgres on the request path and runs *inside* this process, +but ``acb_auth`` cannot import ``gateway``. While the seam lived here the +gateway had two pools no matter how many route packages were converted, so the +ticket's "one engine/pool" could not actually be reached. ``acb_common`` is the +one package everything already depends on, so that is where it went. + +The gateway makes **zero** ``create_async_engine`` calls of its own — see +``tests/unit/test_db_engine_seam.py``, which fails the build if a new one +appears. """ from __future__ import annotations -import os -from typing import Any - -from acb_common import get_settings - -#: Process-wide, created on first use. Module-level rather than app-state so a -#: background loop or a broker handler with no ``Request`` reaches the same pool. -_ENGINE: Any = None -_SESSION_FACTORY: Any = None - - -def async_database_url() -> str: - """The configured database URL, coerced onto the asyncpg driver. - - ``DATABASE_URL`` wins over the setting because LiteLLM's Prisma client needs - the plain ``postgresql://`` form in the environment, so the two disagree by - design and the environment is the one deploy sets. - """ - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - return db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - if db_url.startswith("postgresql://"): - return db_url.replace("postgresql://", "postgresql+asyncpg://") - return db_url - - -#: Ceiling on how long one of OUR sessions may sit `idle in transaction`, in ms. -#: -#: This is a lock-release deadline, not a performance knob. SQLAlchemy's -#: ``AsyncSession`` opens a transaction on first ``execute()`` and holds it until -#: commit/rollback/close, so a handler that reads a row and then awaits a slow -#: network call is `idle in transaction` — holding an ACCESS SHARE lock — for the -#: whole call. That is normal and fine. What is not fine is an await that never -#: returns: on 2026-08-06 a hung LLM call pinned one such transaction for 14h44m, -#: a migration's ``ALTER TABLE`` queued behind its lock, and because Postgres's -#: lock queue is FIFO every later reader of that table queued behind the *waiting* -#: ALTER. Sending mail stopped, and the pool drained behind the blocked readers. -#: -#: ⚠️ MUST stay comfortably above the LLM wall-clock worst case, because the email -#: automation package legitimately awaits completions with a session open. -#: ``acb_llm.client`` bounds one call at 3 attempts x 90s + 6s backoff ≈ 276s. At -#: 600s a genuine retrying completion can never trip this, while a hang is capped -#: at ten minutes instead of unbounded. Raise ``LLM_REQUEST_TIMEOUT_SECS`` and you -#: must raise this too — that coupling is the whole reason both numbers are -#: written down next to their reasoning. -_IDLE_IN_TXN_TIMEOUT_MS = "600000" # 10 minutes - - -def engine_connect_args() -> dict[str, Any]: - """Driver-level connect args every gateway engine should share. - - Two bounds, both about failing instead of hanging: - - * ``timeout`` — asyncpg's CONNECT-phase ceiling, so a slow or unreachable DB - fails fast rather than stalling request handlers. - * ``idle_in_transaction_session_timeout`` — the server-side deadline above. - Set through asyncpg's ``server_settings`` so it rides the connection's - startup packet and applies to every session from this pool, with no - migration and no ``ALTER ROLE``. Scoping it to the app's own connections is - deliberate: ``pg_dump`` and the migration runner connect as the same role - and must NOT inherit an app-tuned deadline. - """ - return { - "timeout": get_settings().db_connect_timeout, - "server_settings": { - "idle_in_transaction_session_timeout": _IDLE_IN_TXN_TIMEOUT_MS, - }, - } - - -def get_engine() -> Any: - """The shared pooled async engine, created on first use.""" - global _ENGINE - if _ENGINE is None: - from sqlalchemy.ext.asyncio import create_async_engine - - _ENGINE = create_async_engine( - async_database_url(), echo=False, pool_pre_ping=True, - pool_size=10, max_overflow=20, pool_recycle=1800, - connect_args=engine_connect_args(), - ) - return _ENGINE - - -def get_session_factory() -> Any: - """The shared ``async_sessionmaker`` over :func:`get_engine`.""" - global _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import async_sessionmaker - - _SESSION_FACTORY = async_sessionmaker(get_engine(), expire_on_commit=False) - return _SESSION_FACTORY - - -async def get_db() -> Any: - """Return a new async session from the shared, pooled engine.""" - return get_session_factory()() +from acb_common.db import ( + async_database_url, + engine_connect_args, + get_db, + get_engine, + get_session_factory, +) + +__all__ = [ + "async_database_url", + "engine_connect_args", + "get_db", + "get_engine", + "get_session_factory", +] diff --git a/apps/services/gateway/gateway/main.py b/apps/services/gateway/gateway/main.py index 483decb29..83060fc45 100644 --- a/apps/services/gateway/gateway/main.py +++ b/apps/services/gateway/gateway/main.py @@ -386,6 +386,18 @@ def _clone(n: str, r: str | None, lp: str | None) -> None: except Exception: pass + # Flush audit writes that are still on worker threads. `acb_audit.record` + # is non-blocking on the event loop (BO-10), which means an event recorded + # moments before shutdown is in flight rather than committed; exiting here + # would cancel it. Last, and after every loop above, so writes those loops + # made on their way out are included. Bounded internally — a wedged audit + # DB cannot hold the shutdown open. + try: + from acb_audit import drain as drain_audit + await drain_audit() + except Exception: + pass + _log.info("gateway.shutdown") diff --git a/apps/services/gateway/gateway/routes/admin/_common.py b/apps/services/gateway/gateway/routes/admin/_common.py index 2f5c6b87f..b589c484a 100644 --- a/apps/services/gateway/gateway/routes/admin/_common.py +++ b/apps/services/gateway/gateway/routes/admin/_common.py @@ -34,7 +34,6 @@ from __future__ import annotations -import os from datetime import datetime, timezone from typing import Any @@ -44,8 +43,13 @@ invalidate_access, permission_matches, ) -from acb_common import get_logger, get_settings +from acb_common import get_logger from fastapi import APIRouter, Depends, HTTPException + +# The shared gateway engine (BO-10) — see the DB section below for why the +# names are re-exported rather than imported at each call site. +from gateway.db import get_db # noqa: F401 +from gateway.db import get_session_factory as _get_session_factory # noqa: F401 from sqlalchemy import text _log = get_logger("gateway.admin") @@ -61,39 +65,17 @@ NON_ASSIGNABLE_ROLES = frozenset({"agent_service"}) -# ── DB (shared pooled async engine, same recipe as routes/apps/_common.py) ─── - -_ENGINE = None -_SESSION_FACTORY = None - - -def _get_session_factory() -> Any: - global _ENGINE, _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import ( # noqa: PLC0415 - async_sessionmaker, - create_async_engine, - ) - - from gateway.db import engine_connect_args - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - elif db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - _ENGINE = create_async_engine( - db_url, echo=False, pool_pre_ping=True, - pool_size=5, max_overflow=10, pool_recycle=1800, - connect_args=engine_connect_args(), - ) - _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) - return _SESSION_FACTORY - - -async def get_db() -> Any: - """Return a new async session from the shared, pooled engine.""" - return _get_session_factory()() +# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── +# +# This package used to build its own engine here, with its own pool of 5+10. +# It now has none: `get_db` and `_get_session_factory` at the top of this module +# are re-exports of the shared seam, so every `from ._common import get_db` in +# this package keeps working with a single pool behind it. +# +# The re-export is deliberate rather than pointing each caller at `gateway.db`. +# Sibling modules import the name from here and the tests monkeypatch it *on the +# sibling* (`monkeypatch.setattr(groups, "get_db", ...)`), so the name has to +# stay resolvable through this module for both to keep working. # ── Auth gate ─────────────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/apps/_common.py b/apps/services/gateway/gateway/routes/apps/_common.py index 110b5be3d..857e93459 100644 --- a/apps/services/gateway/gateway/routes/apps/_common.py +++ b/apps/services/gateway/gateway/routes/apps/_common.py @@ -16,7 +16,6 @@ import hashlib import json -import os import re from collections.abc import Collection, Sequence from pathlib import Path @@ -25,6 +24,10 @@ from acb_auth import UserContext, UserRole, get_current_user from acb_common import get_logger, get_settings from fastapi import APIRouter, Depends, HTTPException + +# The shared gateway engine (BO-10) — see the DB section below. +from gateway.db import get_db as _get_db +from gateway.db import get_session_factory as _get_session_factory # noqa: F401 from sqlalchemy import text _log = get_logger("gateway.apps") @@ -67,39 +70,14 @@ DEFAULT_AI_TIER = "tier-fast" -# ── DB (shared pooled async engine, same recipe as tasks/core.py) ──────────── - -_ENGINE = None -_SESSION_FACTORY = None - - -def _get_session_factory() -> Any: - global _ENGINE, _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import ( - async_sessionmaker, - create_async_engine, - ) - - from gateway.db import engine_connect_args - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - elif db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - _ENGINE = create_async_engine( - db_url, echo=False, pool_pre_ping=True, - pool_size=10, max_overflow=20, pool_recycle=1800, - connect_args=engine_connect_args(), - ) - _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) - return _SESSION_FACTORY - - -async def _get_db() -> Any: - """Return a new async session from the shared, pooled engine.""" - return _get_session_factory()() +# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── +# +# This package used to build its own engine here with its own 10+20 pool. It now +# has none: `_get_db` / `_get_session_factory` at the top of this module are +# re-exports of the shared seam. The private names are kept so that every +# `from ._common import _get_db` in this package — and every test that +# monkeypatches `_get_db` on the sibling module it is imported into — keeps +# working unchanged. # ── Auth gate ──────────────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/apps/lifecycle.py b/apps/services/gateway/gateway/routes/apps/lifecycle.py index 0b2c3cb0d..ad715b862 100644 --- a/apps/services/gateway/gateway/routes/apps/lifecycle.py +++ b/apps/services/gateway/gateway/routes/apps/lifecycle.py @@ -107,7 +107,7 @@ def starter_index_html(name: str) -> str: """The single-file starter app: on-brand via the platform's PRE-INJECTED design system, with a commented ``window.cc`` usage example (the bridge + the ``--cc-*`` tokens and ``.cc-*`` block-kit classes below are - injected by the run/preview frame itself — ``SandboxedHtml.tsx``'s + injected by the run/preview frame itself — ``lib/theme/sandbox-frame.ts``'s ``buildSrcDoc``, the exact same styling every CommandCenter report and generative-UI card already uses. Never redeclare these — see RFC §4.1 and ``apps/agents/agent-app-builder/instructions.md``'s Design section). diff --git a/apps/services/gateway/gateway/routes/email/__init__.py b/apps/services/gateway/gateway/routes/email/__init__.py index e7fcb455f..084a1ab61 100644 --- a/apps/services/gateway/gateway/routes/email/__init__.py +++ b/apps/services/gateway/gateway/routes/email/__init__.py @@ -19,8 +19,10 @@ # Flatten submodule namespaces into the package so the historical public surface # (incl. private ``_helpers`` the scheduler/tests import by name) is preserved. -# NOTE: reassigned module globals (e.g. core._ENGINE) live on their submodule; -# read those via ``email.core.``, not the flattened copy. +# NOTE: this copies BINDINGS once, at import. A name later reassigned on its +# submodule (or monkeypatched there by a test) diverges from the flattened copy +# — read and patch those via ``email..``, never through the +# package namespace. for _mod in (core, transport, automation, digest): for _k, _v in vars(_mod).items(): if not _k.startswith("__"): diff --git a/apps/services/gateway/gateway/routes/email/core.py b/apps/services/gateway/gateway/routes/email/core.py index e2150b8f5..a6cd51dbe 100644 --- a/apps/services/gateway/gateway/routes/email/core.py +++ b/apps/services/gateway/gateway/routes/email/core.py @@ -17,6 +17,9 @@ from acb_common import get_logger, get_settings from fastapi import APIRouter, HTTPException + +# The shared gateway engine (BO-10) — see the DB section below. +from gateway.db import get_session_factory as _get_session_factory from pydantic import BaseModel from sqlalchemy import text from acb_auth import require_feature_router @@ -380,40 +383,20 @@ async def hydrate_message_body(db: Any, message_id: str, user_email: str) -> str return row.body_text or "" -_ENGINE = None - - -_SESSION_FACTORY = None - - -def _get_session_factory(): - global _ENGINE, _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import ( - async_sessionmaker, - create_async_engine, - ) - - from gateway.db import engine_connect_args - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - elif db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - elif "+asyncpg" not in db_url and "postgresql" in db_url: - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - _ENGINE = create_async_engine( - db_url, echo=False, pool_pre_ping=True, - pool_size=10, max_overflow=20, pool_recycle=1800, - # Bounds the connect phase AND how long one of our sessions may sit - # `idle in transaction`. This engine is the one that drained on - # 2026-08-06 — see gateway/db.py::engine_connect_args for why the - # second bound exists and why it is 10 minutes and not 5. - connect_args=engine_connect_args(), - ) - _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) - return _SESSION_FACTORY +# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── +# +# This package used to build its own engine here with its own 10+20 pool — the +# engine that drained on 2026-08-06 behind a transaction left `idle in +# transaction` for 14h44m. Both bounds that came out of that incident now live +# on the shared seam (``acb_common.db.engine_connect_args``, re-exported as +# ``gateway.db``), which is where the reasoning for the 10-minute deadline is +# written down. +# +# `_get_session_factory` at the top of this module is a re-export of that seam. +# `_get_db` stays a real function here only to keep its signature: callers pass +# nothing today, but the parameter is part of the historical surface this +# package flattens into ``gateway.routes.email`` (see __init__.py), and tests +# monkeypatch `_get_db` on the sibling module they exercise. async def _get_db(request_id: str | None = None): diff --git a/apps/services/gateway/gateway/routes/notes/core.py b/apps/services/gateway/gateway/routes/notes/core.py index 362acbcf4..c8cadc2b6 100644 --- a/apps/services/gateway/gateway/routes/notes/core.py +++ b/apps/services/gateway/gateway/routes/notes/core.py @@ -16,8 +16,12 @@ from pathlib import Path from typing import Any -from acb_common import get_logger, get_settings +from acb_common import get_logger from fastapi import APIRouter, HTTPException + +# The shared gateway engine (BO-10) — see the DB section below. +from gateway.db import get_db as _get_db # noqa: F401 +from gateway.db import get_session_factory as _get_session_factory # noqa: F401 from pydantic import BaseModel from sqlalchemy import text from acb_auth import require_feature_router @@ -139,39 +143,14 @@ class PatchMeetingRequest(BaseModel): copilot_enabled: bool | None = None -# ── DB (shared pooled async engine, same recipe as tasks/core.py) ──────────── - -_ENGINE = None -_SESSION_FACTORY = None - - -def _get_session_factory(): - global _ENGINE, _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import ( - async_sessionmaker, - create_async_engine, - ) - - from gateway.db import engine_connect_args - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - elif db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - _ENGINE = create_async_engine( - db_url, echo=False, pool_pre_ping=True, - pool_size=5, max_overflow=10, pool_recycle=1800, - connect_args=engine_connect_args(), - ) - _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) - return _SESSION_FACTORY - - -async def _get_db(): - """Return a new async session from the shared, pooled engine.""" - return _get_session_factory()() +# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── +# +# This package used to build its own engine here with its own 5+10 pool. It now +# has none: `_get_db` / `_get_session_factory` at the top of this module are +# re-exports of the shared seam. The private names are kept so that every +# `from .core import _get_db` in this package — and every test that +# monkeypatches `_get_db` on the sibling module it is imported into — keeps +# working unchanged. # ── Ownership — one predicate, one loader ──────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/settings.py b/apps/services/gateway/gateway/routes/settings.py index 12168a474..715032001 100644 --- a/apps/services/gateway/gateway/routes/settings.py +++ b/apps/services/gateway/gateway/routes/settings.py @@ -1719,3 +1719,170 @@ async def get_cache_info( seen.add(c.provider) deduped.append(c) return deduped + + +# --------------------------------------------------------------------------- +# GET /settings/appearance — the organisation's default look +# PUT /settings/appearance — change it (admin only) +# +# The theming engine (workbench/control_plane/src/lib/theme/) lets each member +# pick a theme, but the ORG DEFAULT is what everyone who has not chosen sees, +# and it is the only way to move the whole company onto one look at once. +# That decision has to outlive a browser, so it lives here. +# +# WHAT THIS ROUTE DELIBERATELY DOES NOT KNOW +# ------------------------------------------ +# Which themes exist. That list is `THEMES` in the frontend's themes.ts, and +# the whole point of the engine is that adding a theme is one manifest entry — +# if the gateway validated `theme_id` against a copy of that list, every new +# theme would need a backend deploy too, and a mismatch between the two lists +# would reject a theme the app can actually render. +# +# So `theme_id` is stored as an opaque identifier, constrained only to the +# shape that is safe to put in a CSS attribute selector (the same rule +# `buildThemeCss` enforces). The frontend re-validates on read and falls back +# to its default for an id it does not recognise — which it must do anyway, +# since a theme can be removed from the app long after this row named it. +# +# Personal preferences are NOT stored here. They are per-device, they change +# often, and round-tripping them would add a request to every page load to +# render something the pre-paint boot script has already applied from +# localStorage. +# --------------------------------------------------------------------------- + +_APPEARANCE_KEY = "appearance" + +#: Same rule as SAFE_ID in the frontend's css.ts: a theme id ends up inside +#: `html[data-theme="…"]`, so restrict it to characters needing no escaping. +_THEME_ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$") + +_MODES = ("dark", "light") +_DENSITIES = ("compact", "default", "comfortable") + +_APPEARANCE_DEFAULTS: dict[str, Any] = { + "themeId": "rapidtool", + "mode": "dark", + "density": "default", + "allowUserOverride": True, +} + + +class OrgAppearance(BaseModel): + """The organisation-wide default look.""" + + themeId: str = _APPEARANCE_DEFAULTS["themeId"] + mode: str = _APPEARANCE_DEFAULTS["mode"] + density: str = _APPEARANCE_DEFAULTS["density"] + allowUserOverride: bool = True + + +class AppearanceResponse(BaseModel): + org: OrgAppearance + #: Who last changed it and when — an org-wide setting affects everybody, + #: so "who did this" is the first question when the company's UI changes. + updatedBy: str = "" + updatedAt: str = "" + + +def _coerce_appearance(raw: Any) -> tuple[OrgAppearance, str, str]: + """Normalise a stored blob into a usable default. + + Every field falls back independently: a row written by an older or newer + version of the app should degrade one key at a time rather than reset the + whole organisation's look. + """ + blob = raw if isinstance(raw, dict) else {} + org = blob.get("org") if isinstance(blob.get("org"), dict) else blob + + theme_id = str(org.get("themeId") or "") + if not _THEME_ID_RE.match(theme_id) or len(theme_id) > 64: + theme_id = _APPEARANCE_DEFAULTS["themeId"] + + mode = org.get("mode") + if mode not in _MODES: + mode = _APPEARANCE_DEFAULTS["mode"] + + density = org.get("density") + if density not in _DENSITIES: + density = _APPEARANCE_DEFAULTS["density"] + + allow = org.get("allowUserOverride") + if not isinstance(allow, bool): + allow = _APPEARANCE_DEFAULTS["allowUserOverride"] + + return ( + OrgAppearance( + themeId=theme_id, mode=mode, density=density, allowUserOverride=allow + ), + str(blob.get("updatedBy") or ""), + str(blob.get("updatedAt") or ""), + ) + + +@router.get("/appearance", response_model=AppearanceResponse) +async def get_appearance( + _user: UserContext = Depends(get_current_user), +) -> AppearanceResponse: + """Return the org's default theme, mode and density. + + Readable by any signed-in member, not just admins: every client needs it on + load to know what to render for someone who has not picked a theme. + """ + from acb_common import load_org_setting # noqa: PLC0415 + + org, updated_by, updated_at = _coerce_appearance( + load_org_setting(_APPEARANCE_KEY, default={}) + ) + return AppearanceResponse(org=org, updatedBy=updated_by, updatedAt=updated_at) + + +@router.put( + "/appearance", + response_model=AppearanceResponse, + dependencies=[require_permission("admin:settings:manage")], +) +async def put_appearance( + body: OrgAppearance, + user: UserContext = Depends(get_current_user), +) -> AppearanceResponse: + """Set the org's default look. Admin-gated — this changes everyone's UI.""" + from datetime import datetime, timezone # noqa: PLC0415 + + from acb_common import save_org_setting # noqa: PLC0415 + + # Re-validate rather than trusting the model: pydantic types `mode` and + # `density` as plain strings (they are open vocabularies on the frontend), + # so the enum check has to happen here or an arbitrary value reaches every + # member's browser. + if body.mode not in _MODES: + raise HTTPException(status_code=400, detail=f"mode must be one of {list(_MODES)}") + if body.density not in _DENSITIES: + raise HTTPException( + status_code=400, detail=f"density must be one of {list(_DENSITIES)}" + ) + if not _THEME_ID_RE.match(body.themeId) or len(body.themeId) > 64: + raise HTTPException( + status_code=400, + detail="themeId must be lowercase alphanumeric with hyphens", + ) + + updated_at = datetime.now(timezone.utc).isoformat() + save_org_setting( + _APPEARANCE_KEY, + { + "org": body.model_dump(), + "updatedBy": user.email or "", + "updatedAt": updated_at, + }, + updated_by=user.email or "", + ) + _log.info( + "settings.appearance.updated", + theme_id=body.themeId, + mode=body.mode, + allow_user_override=body.allowUserOverride, + by=user.email, + ) + return AppearanceResponse( + org=body, updatedBy=user.email or "", updatedAt=updated_at + ) diff --git a/apps/services/gateway/gateway/routes/whatsapp/core.py b/apps/services/gateway/gateway/routes/whatsapp/core.py index 79f87a420..63f20961b 100644 --- a/apps/services/gateway/gateway/routes/whatsapp/core.py +++ b/apps/services/gateway/gateway/routes/whatsapp/core.py @@ -9,11 +9,14 @@ from __future__ import annotations import json -import os from typing import Any -from acb_common import get_logger, get_settings +from acb_common import get_logger from fastapi import APIRouter, HTTPException + +# The shared gateway engine (BO-10) — see the DB section below. +from gateway.db import get_db as _get_db # noqa: F401 +from gateway.db import get_session_factory as _get_session_factory # noqa: F401 from pydantic import BaseModel from acb_auth import require_feature_router @@ -113,39 +116,14 @@ class WhatsAppMessageModel(BaseModel): sent_at: str | None = None -# ── DB session (own pooled engine, same pattern as email.core) ──────────────── - -_ENGINE = None -_SESSION_FACTORY = None - - -def _get_session_factory(): - global _ENGINE, _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import ( - async_sessionmaker, - create_async_engine, - ) - - from gateway.db import engine_connect_args - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - elif db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - _ENGINE = create_async_engine( - db_url, echo=False, pool_pre_ping=True, - pool_size=5, max_overflow=10, pool_recycle=1800, - connect_args=engine_connect_args(), - ) - _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) - return _SESSION_FACTORY - - -async def _get_db(): - """Return a new async session from the shared, pooled engine.""" - return _get_session_factory()() +# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── +# +# This package used to build its own engine here with its own 5+10 pool. It now +# has none: `_get_db` / `_get_session_factory` at the top of this module are +# re-exports of the shared seam. The private names are kept so that every +# `from .core import _get_db` in this package — and every test that +# monkeypatches `_get_db` on the sibling module it is imported into — keeps +# working unchanged. # ── provider adapter ────────────────────────────────────────────────────────── diff --git a/apps/services/gateway/gateway/routes/workflows/core.py b/apps/services/gateway/gateway/routes/workflows/core.py index 7e4d716e4..b6f929365 100644 --- a/apps/services/gateway/gateway/routes/workflows/core.py +++ b/apps/services/gateway/gateway/routes/workflows/core.py @@ -14,13 +14,16 @@ from __future__ import annotations import json -import os import secrets from typing import Any from acb_auth import UserContext, require_feature_router from acb_common import get_logger, get_settings from fastapi import APIRouter, HTTPException + +# The shared gateway engine (BO-10) — see the DB section below. +from gateway.db import get_db as _get_db # noqa: F401 +from gateway.db import get_session_factory as _get_session_factory # noqa: F401 from sqlalchemy import text _log = get_logger("gateway.workflows") @@ -41,43 +44,14 @@ MAX_RUNS_PAGE = 100 HOOK_RATE_LIMIT_PER_MINUTE = 60 # per hook token -# ── DB (shared pooled async engine, same recipe as apps/_common.py) ────────── - -_ENGINE = None -_SESSION_FACTORY = None - - -def _get_session_factory() -> Any: - global _ENGINE, _SESSION_FACTORY - if _SESSION_FACTORY is None: - from sqlalchemy.ext.asyncio import ( - async_sessionmaker, - create_async_engine, - ) - - from gateway.db import engine_connect_args - settings = get_settings() - db_url = os.environ.get("DATABASE_URL", settings.database_url) - if "postgresql+psycopg" in db_url: - db_url = db_url.replace("postgresql+psycopg", "postgresql+asyncpg") - elif db_url.startswith("postgresql://"): - db_url = db_url.replace("postgresql://", "postgresql+asyncpg://") - _ENGINE = create_async_engine( - db_url, - echo=False, - pool_pre_ping=True, - pool_size=5, - max_overflow=10, - pool_recycle=1800, - connect_args=engine_connect_args(), - ) - _SESSION_FACTORY = async_sessionmaker(_ENGINE, expire_on_commit=False) - return _SESSION_FACTORY - - -async def _get_db() -> Any: - """Return a new async session from the shared, pooled engine.""" - return _get_session_factory()() +# ── DB (the one shared gateway engine — gateway/db.py, BO-10) ──────────────── +# +# This package used to build its own engine here with its own 5+10 pool. It now +# has none: `_get_db` / `_get_session_factory` at the top of this module are +# re-exports of the shared seam. The private names are kept so that every +# `from .core import _get_db` in this package — and every test that +# monkeypatches `_get_db` on the sibling module it is imported into — keeps +# working unchanged. # ── Small helpers ──────────────────────────────────────────────────────────── diff --git a/docs/THEMING_ENGINE_SCOPE.md b/docs/THEMING_ENGINE_SCOPE.md new file mode 100644 index 000000000..94e978617 --- /dev/null +++ b/docs/THEMING_ENGINE_SCOPE.md @@ -0,0 +1,374 @@ +# CommandCenter Theming Engine — Scoping Document + +**Status:** Built and shipped through the coverage work (phases 1–4 plus the +full icon migration, org-default backend, contrast gate and third-party +surfaces). See "What was built" below; the rest +of this document is the original research and remains the design rationale. +**Scope:** `workbench/control_plane` (Next.js 16 · React 19 · Tailwind CSS v4) +**Goal:** A theming engine that can restyle the entire Control Plane — colors, +fonts, radii, shadows, icon packs, component "personality" — switchable on the +fly from Settings, with company-wide defaults. Example target themes: the +current RapidTool look, a Microsoft Fluent/Metro ("Lumia") look, a Google +Material look. + +--- + +## 0. What was built + +Four themes, switchable from **Settings → Appearance**, each changing colours, +fonts, corner radius, effects and the icon pack across every page: + +| Theme | Look | Icons | Fonts | +|---|---|---|---| +| **RapidTool** (default) | The original design, preserved token-for-token | Lucide | Geist | +| **Fluent** | Microsoft Fluent 2 — 4px corners, acrylic, no glow | Fluent System Icons | Segoe UI → Inter | +| **Material** | Google Material 3 — 16px corners, flat + elevation | Material Symbols Rounded | Roboto | +| **Graphite** | Low-distraction monochrome, monospace headings | Lucide | Geist / Geist Mono | + +**How it works.** A theme is a manifest in `src/lib/theme/themes.ts` — colours +for both modes, font stacks, shape, effects, icon pack. `css.ts` compiles every +manifest to an `html[data-theme="…"]` custom-property scope, inlined once in +the document head, so switching is a single attribute write with no fetch and +no flash. *Style* (`data-theme`) and *mode* (the next-themes `.light` class) +are independent axes. A pre-paint boot script applies the stored preference +before the first frame. + +Adding a theme is a manifest entry — no component, CSS or Tailwind change. + +**Icons** go through ``, using Lucide names as the shared +vocabulary. **Every call site in the app is migrated** — 158 files; the only +modules still importing `lucide-react` are `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). All 251 Lucide icons the app uses resolve in both non-Lucide packs. +Those packs are pruned Iconify collections (275 icons, ~115 KB and ~106 KB) +built by `scripts/build-icon-packs.mjs`, fetched lazily only when a theme needs +them, and rendered offline. + +`themedIcon(name)` returns a memoised component bound to one name, for tables +that store an icon rather than rendering it inline; `ThemedIcon` is its type. + +**Apps and generated UI follow the theme too.** Custom Apps, generative-UI +cards and React artifacts run in an opaque-origin iframe: they inherit nothing — +not our stylesheet, not `data-theme`, not one custom property. They already had +a token vocabulary (`--cc-*`, written into `agent-app-builder`'s instructions +and `acb_skills/design.md`), but its **values were hand-written RapidTool +literals switching only on light/dark**, so every app ever built stayed +RapidTool-blue while the shell around it turned Fluent or Material. Nothing +errored; it just quietly did not theme. + +`app-tokens.ts` now derives that block from the active manifest, and +`sandbox-frame.ts` builds the frame around it. Colour, ink pairs, type, shape, +motion and the theme's *control personality* (Material's pill buttons and 8% +state layer, Fluent's tight ring, Graphite's uppercase labels) all cross, so an +app built a year ago picks up a new theme's behaviour and not merely its +palette. Icons cross as SVG resolved from the active pack. The block is applied +twice: in the frame's first `` + + `` + `${mount}${BRIDGE}`; return ( `` + + ``, + ); + return page.frameLocator("#f"); +} + +/** + * Computed value of a `--cc-*` variable, read from INSIDE the frame. + * + * It has to be read from inside: the frame is `allow-scripts` without + * `allow-same-origin`, so its origin is opaque and `contentDocument` is null to + * us. That is the security property the whole sandbox rests on, and it is worth + * bumping into here — it is exactly why the tokens have to be handed across + * rather than inherited. + */ +const readVar = (frame: FrameLocator, name: string) => + frame + .locator("#panel") + .evaluate( + (_el, n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim(), + name, + ); + +test.describe("tokens cross the boundary", () => { + test("an app styled with tokens renders against the active theme", async ({ page }) => { + const frame = await mount(page, "rapidtool"); + const t = resolveTheme("rapidtool").colors.dark; + + // The declarations survived the CSP and resolved — the baseline claim + // everything else here depends on. + await expect(frame.locator("#title")).toHaveText("Orders"); + expect(await readVar(frame, "--cc-primary")).toBe(t.primary); + expect(await readVar(frame, "--cc-card")).toBe(t.card); + }); + + test("switching theme changes what the app looks like", async ({ page }) => { + // THE regression. Before this, both of these were the same RapidTool blue. + const rapid = await readVar(await mount(page, "rapidtool"), "--cc-primary"); + const material = await readVar(await mount(page, "material"), "--cc-primary"); + expect(material).not.toBe(rapid); + }); + + test("control personality crosses, not just colour", async ({ page }) => { + // An app built a year ago should pick up Material's pill buttons from a + // theme switch. Colour alone would make it "RapidTool with Material's + // palette", which is not the same product. + const material = await mount(page, "material"); + expect(await readVar(material, "--cc-button-radius")).toBe("9999px"); + expect(await readVar(material, "--cc-control-state-layer")).toBe("0.08"); + const graphite = await mount(page, "graphite"); + expect(await readVar(graphite, "--cc-control-label-transform")).toBe("uppercase"); + }); + + test("ink on a coloured fill is legible on every theme", async ({ page }) => { + // Was hardcoded `hsl(20 14% 12%)`, i.e. near-black, which is only legible + // over a YELLOW warning. A theme with a dark warning rendered black on dark. + for (const id of ["rapidtool", "fluent", "material", "graphite"]) { + const frame = await mount(page, id); + const fill = await readVar(frame, "--cc-warning"); + const ink = await readVar(frame, "--cc-warning-fg"); + expect(ink, id).toBeTruthy(); + expect(ink, id).not.toBe(fill); + } + }); + + test("the font stack resolves rather than collapsing to nothing", async ({ page }) => { + // Theme manifests write `var(--font-geist-sans), …` — a next/font handle + // registered on OUR and undefined in here. An unresolvable var() + // invalidates the whole font-family, so exporting it verbatim gave apps no + // themed font at all, silently. + const frame = await mount(page, "fluent"); + const font = await frame.locator("#mono").evaluate((el) => getComputedStyle(el).fontFamily); + expect(font).not.toBe(""); + expect(font).not.toContain("var("); + expect(font.toLowerCase()).toContain("cascadia"); + }); + + test("light mode swaps colour without disturbing the theme's shape", async ({ page }) => { + const dark = await mount(page, "material", "dark"); + const darkBg = await readVar(dark, "--cc-bg"); + const radius = await readVar(dark, "--cc-button-radius"); + const light = await mount(page, "material", "light"); + expect(await readVar(light, "--cc-bg")).not.toBe(darkBg); + expect(await readVar(light, "--cc-button-radius")).toBe(radius); + }); +}); + +test.describe("live theme changes", () => { + test("a running app restyles without being remounted", async ({ page }) => { + // Why this must be a patch and not a srcDoc rebuild: a rebuild remounts the + // document, and a published app would throw away whatever the person had + // typed into it every time somebody changed theme. + const frame = await mount(page, "rapidtool"); + const before = await readVar(frame, "--cc-primary"); + + // Stand in for user state the app is holding. + await frame.locator("#title").evaluate((el) => { + el.setAttribute("data-user-state", "typed-but-unsaved"); + }); + + const material = resolveTheme("material"); + await page.evaluate( + ([vars, icons]) => { + const win = (document.getElementById("f") as HTMLIFrameElement).contentWindow!; + win.postMessage({ __cc: true, kind: "theme", mode: "dark", vars, icons }, "*"); + }, + [appTokenMap(material, "dark"), { Plus: '' }] as const, + ); + + await expect + .poll(() => readVar(frame, "--cc-primary")) + .toBe(material.colors.dark.primary); + expect(await readVar(frame, "--cc-primary")).not.toBe(before); + + // The document was never rebuilt, so the state is still there. + await expect(frame.locator("#title")).toHaveAttribute( + "data-user-state", + "typed-but-unsaved", + ); + }); + + test("icon placeholders re-resolve to the new pack", async ({ page }) => { + const frame = await mount(page, "rapidtool"); + await expect(frame.locator("#ico svg")).toHaveAttribute("data-pack", "lucide"); + + await page.evaluate(() => { + const win = (document.getElementById("f") as HTMLIFrameElement).contentWindow!; + win.postMessage( + { + __cc: true, + kind: "theme", + mode: "dark", + vars: {}, + icons: { Plus: '' }, + }, + "*", + ); + }); + + await expect(frame.locator("#ico svg")).toHaveAttribute("data-pack", "material"); + }); + + test("the patch cannot write outside the --cc-* namespace", async ({ page }) => { + // A sibling frame that got a handle to this window should at worst be able + // to recolour it — not reach other properties, and not break out of the + // declaration (setProperty is a value API, so a ';' is rejected, not parsed). + const frame = await mount(page, "rapidtool"); + await page.evaluate(() => { + const win = (document.getElementById("f") as HTMLIFrameElement).contentWindow!; + win.postMessage( + { + __cc: true, + kind: "theme", + vars: { "--evil": "red", "--cc-primary": "rgb(1, 2, 3); --evil2: red" }, + }, + "*", + ); + }); + + await expect.poll(() => readVar(frame, "--evil")).toBe(""); + expect(await readVar(frame, "--evil2")).toBe(""); + }); + + test("a message that is not ours is ignored", async ({ page }) => { + const frame = await mount(page, "rapidtool"); + const before = await readVar(frame, "--cc-primary"); + await page.evaluate(() => { + const win = (document.getElementById("f") as HTMLIFrameElement).contentWindow!; + win.postMessage({ kind: "theme", vars: { "--cc-primary": "red" } }, "*"); + win.postMessage("theme", "*"); + }); + expect(await readVar(frame, "--cc-primary")).toBe(before); + }); +}); + +test("the bridge ships the theme listener", () => { + // Cheap guard on the thing every test above depends on: if the listener is + // ever dropped from BRIDGE, the frame stops responding to theme changes and + // the only symptom is an app that needs a reload to restyle. + expect(BRIDGE).toContain('d.kind !== "theme"'); +}); diff --git a/workbench/control_plane/e2e/theming.spec.ts b/workbench/control_plane/e2e/theming.spec.ts new file mode 100644 index 000000000..80883c019 --- /dev/null +++ b/workbench/control_plane/e2e/theming.spec.ts @@ -0,0 +1,276 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * The theming engine, end to end. + * + * Unit tests cover the manifests and the generated CSS as text. What they + * cannot check is the part that actually decides whether a theme applies: CSS + * specificity and cascade order in a real browser. `globals.css` keeps a + * fallback copy of the default theme at `:root` (0,1,0) and the generated + * scopes sit at `html[data-theme=…]` (0,1,1) — if that relationship ever + * inverts, every theme silently stops working while every unit test still + * passes. These tests read computed style, so they see what the user sees. + * + * They also pin the two axes as independent: switching colour mode must not + * disturb the active theme's shape, type or effects. + */ + +const readVar = (page: Page, prop: string) => + page.evaluate( + (p) => getComputedStyle(document.documentElement).getPropertyValue(p).trim(), + prop, + ); + +/** Load the app with a theme already stored, as a returning member would. */ +async function loadWithTheme(page: Page, themeId: string, mode: "dark" | "light" = "dark") { + await page.goto("/"); + await page.evaluate( + ([t, m]) => { + localStorage.setItem("cc-theme", t); + localStorage.setItem("theme", m); + }, + [themeId, mode], + ); + await page.goto("/"); +} + +/** Split the page's glyphs by which library drew them. Lucide self-labels. */ +const countGlyphs = (page: Page) => + page.evaluate(() => { + const svgs = [...document.querySelectorAll("svg")]; + return { + lucide: svgs.filter((s) => s.getAttribute("class")?.includes("lucide")).length, + themed: svgs.filter((s) => !s.getAttribute("class")?.includes("lucide")).length, + }; + }); + +test.describe("theme tokens", () => { + test("the server renders a theme scope before any script runs", async ({ page }) => { + await page.goto("/"); + // Without this the first paint would be unstyled for anyone with + // JavaScript disabled or still loading. + await expect(page.locator("html")).toHaveAttribute("data-theme", "rapidtool"); + expect(await readVar(page, "--primary")).toBe("hsl(198 89% 50%)"); + expect(await readVar(page, "--radius")).toBe("0.75rem"); + }); + + test("a stored theme is applied before first paint", async ({ page }) => { + await loadWithTheme(page, "material"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "material"); + expect(await readVar(page, "--radius")).toBe("1rem"); + }); + + test("an unknown stored theme falls back instead of leaving the app unstyled", async ({ + page, + }) => { + await loadWithTheme(page, "theme-that-was-deleted"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "rapidtool"); + expect(await readVar(page, "--primary")).toBe("hsl(198 89% 50%)"); + }); + + test("switching theme re-resolves colour, shape and effect tokens together", async ({ + page, + }) => { + await loadWithTheme(page, "fluent"); + expect(await readVar(page, "--primary")).toBe("hsl(197 100% 65%)"); + expect(await readVar(page, "--radius")).toBe("0.25rem"); + expect(await readVar(page, "--glass-blur")).toBe("30px"); + // Fluent has no glow; a zero here is what switches `.tech-glow` off. + expect(await readVar(page, "--glow-strength")).toBe("0"); + + await loadWithTheme(page, "material"); + expect(await readVar(page, "--primary")).toBe("hsl(258 100% 87%)"); + expect(await readVar(page, "--radius")).toBe("1rem"); + // Material is flat: depth comes from --elevation, not blur. + expect(await readVar(page, "--glass-blur")).toBe("0px"); + }); + + test("the theme's font stack reaches the body", async ({ page }) => { + await loadWithTheme(page, "material"); + const font = await page.evaluate(() => getComputedStyle(document.body).fontFamily); + expect(font).toMatch(/Roboto/i); + + await loadWithTheme(page, "fluent"); + const fluentFont = await page.evaluate(() => getComputedStyle(document.body).fontFamily); + expect(fluentFont).toContain("Segoe UI"); + }); + + test("the whole Tailwind radius scale follows --radius", async ({ page }) => { + await loadWithTheme(page, "fluent"); + const radii = await page.evaluate(() => { + const el = document.createElement("div"); + document.body.appendChild(el); + const at = (cls: string) => { + el.className = cls; + return getComputedStyle(el).borderRadius; + }; + const out = { sm: at("rounded-sm"), lg: at("rounded-lg"), xl: at("rounded-xl") }; + el.remove(); + return out; + }); + expect(radii.lg).toBe("4px"); + expect(radii.xl).toBe("4px"); + // `rounded-sm` is --radius minus 4px; at Fluent's 0.25rem that is zero, + // not a negative value the browser would discard. + expect(radii.sm).toBe("0px"); + }); +}); + +test.describe("colour mode is independent of theme", () => { + test("light mode swaps colours but keeps the theme's structure", async ({ page }) => { + await loadWithTheme(page, "fluent", "light"); + // 41%, not Fluent's nominal 42%: white-on-42% measures 4.44:1, just under + // WCAG AA. See src/lib/theme/contrast.test.ts. + expect(await readVar(page, "--primary")).toBe("hsl(206 100% 41%)"); + expect(await readVar(page, "--background")).toBe("hsl(0 0% 95%)"); + // Structural tokens live only on the base scope; light inherits them. + expect(await readVar(page, "--radius")).toBe("0.25rem"); + + await loadWithTheme(page, "fluent", "dark"); + expect(await readVar(page, "--primary")).toBe("hsl(197 100% 65%)"); + }); +}); + +test.describe("icon packs", () => { + test("a lucide theme draws every glyph with lucide", async ({ page }) => { + await loadWithTheme(page, "rapidtool"); + const glyphs = await countGlyphs(page); + expect(glyphs.lucide).toBeGreaterThan(20); + expect(glyphs.themed).toBe(0); + }); + + test("the Fluent theme swaps the chrome onto Fluent System Icons", async ({ page }) => { + await loadWithTheme(page, "fluent"); + // The pack is a lazily-fetched chunk, so the swap lands a tick after paint. + await expect + .poll(async () => (await countGlyphs(page)).themed, { timeout: 15_000 }) + .toBeGreaterThan(20); + + expect((await countGlyphs(page)).lucide).toBe(0); + + const glyph = await page.evaluate(() => { + const svg = [...document.querySelectorAll("svg")].find( + (s) => !s.getAttribute("class")?.includes("lucide"), + ); + return { viewBox: svg?.getAttribute("viewBox"), hasPath: !!svg?.querySelector("path") }; + }); + // Real geometry on Fluent's 20px UI grid — not an empty placeholder svg. + expect(glyph.hasPath).toBe(true); + expect(glyph.viewBox).toBe("0 0 20 20"); + }); + + test("the Material theme swaps the chrome onto Material Symbols", async ({ page }) => { + await loadWithTheme(page, "material"); + await expect + .poll(async () => (await countGlyphs(page)).themed, { timeout: 15_000 }) + .toBeGreaterThan(20); + + const viewBox = await page.evaluate( + () => + [...document.querySelectorAll("svg")] + .find((s) => !s.getAttribute("class")?.includes("lucide")) + ?.getAttribute("viewBox"), + ); + expect(viewBox).toBe("0 0 24 24"); + }); + + test("returning to a lucide theme restores lucide glyphs", async ({ page }) => { + await loadWithTheme(page, "graphite"); + const glyphs = await countGlyphs(page); + expect(glyphs.lucide).toBeGreaterThan(20); + expect(glyphs.themed).toBe(0); + }); +}); + +test.describe("user preferences", () => { + test("density scales the root font size", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => localStorage.setItem("cc-density", "compact")); + await page.goto("/"); + // 16px browser default × the compact scale of 0.92. + expect(await page.evaluate(() => getComputedStyle(document.documentElement).fontSize)).toBe( + "14.72px", + ); + }); + + test("an accent override replaces the theme's primary", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => localStorage.setItem("cc-accent", "rgb(255, 0, 0)")); + await page.goto("/"); + expect(await readVar(page, "--primary")).toBe("rgb(255, 0, 0)"); + }); + + test("a malformed stored accent is ignored rather than applied", async ({ page }) => { + await page.goto("/"); + await page.evaluate(() => + localStorage.setItem("cc-accent", "red; background: url(https://evil.test/x)"), + ); + await page.goto("/"); + // The boot script writes through setProperty, so the CSSOM rejects the + // value outright; nothing is injected into the stylesheet. + expect(await readVar(page, "--primary")).toBe("hsl(198 89% 50%)"); + }); +}); + +test.describe("control personality", () => { + /** + * The point of the shared primitives: a theme changes how a control BEHAVES, + * not just what colour it is. These read computed style off a real button, + * because none of it is visible to a type-checker and only some of it is + * expressible as a class. + */ + const measureButton = (page: Page) => + page.evaluate(() => { + const el = document.createElement("button"); + el.className = + "cc-control cc-button cc-button-filled bg-primary text-primary-foreground px-3 py-1.5 text-xs"; + document.body.appendChild(el); + const cs = getComputedStyle(el); + const out = { + radius: cs.borderTopLeftRadius, + filledBorder: cs.borderTopWidth, + weight: cs.fontWeight, + transform: cs.textTransform, + hasStateLayer: getComputedStyle(el, "::after").content !== "none", + }; + el.remove(); + return out; + }); + + test("Material renders pill buttons with a state layer", async ({ page }) => { + await loadWithTheme(page, "material"); + const b = await measureButton(page); + expect(b.radius).toBe("9999px"); + expect(b.hasStateLayer).toBe(true); + // 8% overlay of the foreground colour, M3's hover treatment. + expect(await readVar(page, "--control-state-layer")).toBe("0.08"); + expect(await readVar(page, "--control-focus-ring")).toBe("3px"); + }); + + test("Fluent strokes its solid buttons and weights labels heavier", async ({ page }) => { + await loadWithTheme(page, "fluent"); + const b = await measureButton(page); + expect(b.radius).toBe("4px"); + // The 1px stroke is what stops a Fluent button reading as a flat block. + expect(b.filledBorder).toBe("1px"); + expect(b.weight).toBe("600"); + }); + + test("Graphite upper-cases control labels", async ({ page }) => { + await loadWithTheme(page, "graphite"); + const b = await measureButton(page); + expect(b.transform).toBe("uppercase"); + expect(b.radius).toBe("2px"); + }); + + test("the default theme's buttons are unchanged by the primitives", async ({ page }) => { + // Adoption must not shift the shipped look: same radius, same weight, no + // border on a filled button, no state layer. + await loadWithTheme(page, "rapidtool"); + const b = await measureButton(page); + expect(b.radius).toBe("12px"); + expect(b.filledBorder).toBe("0px"); + expect(b.weight).toBe("500"); + expect(b.transform).toBe("none"); + }); +}); diff --git a/workbench/control_plane/package-lock.json b/workbench/control_plane/package-lock.json index 11101776e..621bb9922 100644 --- a/workbench/control_plane/package-lock.json +++ b/workbench/control_plane/package-lock.json @@ -8,6 +8,7 @@ "name": "control_plane", "version": "0.1.0", "dependencies": { + "@iconify/react": "^6.0.2", "@monaco-editor/react": "^4.7.0", "@tiptap/extension-image": "^3.29.2", "@tiptap/extension-table": "^3.29.2", @@ -41,6 +42,9 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@iconify-json/fluent": "^1.2.54", + "@iconify-json/material-symbols": "^1.2.88", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", @@ -67,6 +71,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@antfu/install-pkg/node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@auth/core": { "version": "0.41.2", "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz", @@ -1042,6 +1070,59 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify-json/fluent": { + "version": "1.2.54", + "resolved": "https://registry.npmjs.org/@iconify-json/fluent/-/fluent-1.2.54.tgz", + "integrity": "sha512-5qtBUKoCZHK1+GVauwfpNM8lBuay8mOTWXBxgh83A/aDXybs9ROlGQUy70v01bpARg4K3+tenAB7DjY//y2EHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify-json/material-symbols": { + "version": "1.2.88", + "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.88.tgz", + "integrity": "sha512-lx5EwRkKckCkczozRrKiyrtD+K2/tIn+/kf9HFrUez/6DGMQKb2sI7b+/agN7jItLvmY/VYUajs/ZcEjj6xiaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@iconify/types": "*" + } + }, + "node_modules/@iconify/react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.2.tgz", + "integrity": "sha512-SMmC2sactfpJD427WJEDN6PMyznTFMhByK9yLW0gOTtnjzzbsi/Ke/XqsumsavFPwNiXs8jSiYeZTmLCLwO+Fg==", + "license": "MIT", + "dependencies": { + "@iconify/types": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/cyberalien" + }, + "peerDependencies": { + "react": ">=16" + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.4.tgz", + "integrity": "sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "import-meta-resolve": "^4.2.0" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -6987,6 +7068,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -9638,6 +9730,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", diff --git a/workbench/control_plane/package.json b/workbench/control_plane/package.json index 3d87c05cb..d7fc3b7cf 100644 --- a/workbench/control_plane/package.json +++ b/workbench/control_plane/package.json @@ -9,9 +9,11 @@ "lint": "eslint", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npm run build && playwright test" + "test:e2e": "npm run build && playwright test", + "build:icons": "node scripts/build-icon-packs.mjs" }, "dependencies": { + "@iconify/react": "^6.0.2", "@monaco-editor/react": "^4.7.0", "@tiptap/extension-image": "^3.29.2", "@tiptap/extension-table": "^3.29.2", @@ -45,6 +47,9 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@iconify-json/fluent": "^1.2.54", + "@iconify-json/material-symbols": "^1.2.88", + "@iconify/utils": "^3.1.4", "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@tailwindcss/typography": "^0.5.19", diff --git a/workbench/control_plane/playwright.config.ts b/workbench/control_plane/playwright.config.ts index 342232103..be158c1da 100644 --- a/workbench/control_plane/playwright.config.ts +++ b/workbench/control_plane/playwright.config.ts @@ -17,7 +17,17 @@ export default defineConfig({ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + // Escape hatch for environments where the browser build @playwright/test + // pins is not the one installed — a container image that ships Chromium + // at a fixed path, for instance. Unset it and Playwright resolves its + // own bundled browser exactly as before, so this changes nothing by + // default; it only removes the need to patch this file by hand. + ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH + ? { launchOptions: { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } } + : {}), + }, }, ], webServer: { diff --git a/workbench/control_plane/scripts/build-icon-packs.mjs b/workbench/control_plane/scripts/build-icon-packs.mjs new file mode 100644 index 000000000..ea16e40c7 --- /dev/null +++ b/workbench/control_plane/scripts/build-icon-packs.mjs @@ -0,0 +1,456 @@ +/** + * Builds the offline icon-pack data used by ``. + * + * The theming engine speaks Lucide names as its canonical icon vocabulary + * (see src/lib/theme/icon-registry.ts). This script resolves each of those + * names onto a real icon in the Fluent and Material Symbols collections, then + * writes out: + * + * src/lib/theme/icon-data/.json — a PRUNED Iconify collection holding + * only the icons we actually map + * + * Pruning matters: the full Fluent collection is 20k icons and the full + * Material Symbols one 16k. We ship the ~150 we use, so the packs cost a few + * tens of KB instead of several megabytes, and render with no network calls. + * + * Run with: npm run build:icons + */ + +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { getIcons } from "@iconify/utils"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(HERE, ".."); +const OUT_DIR = resolve(ROOT, "src/lib/theme/icon-data"); + +/** + * Candidate base names per pack, in preference order. The first candidate that + * exists in the collection wins, which keeps this file readable while still + * failing loudly when a concept has no equivalent in a pack. + * + * Keys are Lucide component names — the vocabulary every call site already + * uses. + */ +const MAP = { + // ── Arrived with the CRM and Projects apps (merged 2026-08-07) ─────────── + // Without a mapping these render Lucide on every theme — graceful, but they + // would be the only Lucide glyphs in a Fluent or Material screen. + Kanban: { fluent: ["board"], material: ["view-kanban"] }, + IndianRupee: { fluent: ["currency-rupee-indian", "money"], material: ["currency-rupee"] }, + UserCheck: { fluent: ["person-available", "person-accounts"], material: ["how-to-reg", "person-check"] }, + CircleAlert: { fluent: ["error-circle"], material: ["error"] }, + Ban: { fluent: ["prohibited"], material: ["block"] }, + Car: { fluent: ["vehicle-car"], material: ["directions-car"] }, + Flame: { fluent: ["fire"], material: ["local-fire-department"] }, + Siren: { fluent: ["alert-urgent"], material: ["emergency", "siren"] }, + // ── Status / feedback ──────────────────────────────────────────────────── + Loader2: { fluent: ["spinner-ios"], material: ["progress-activity"] }, + Loader: { fluent: ["spinner-ios"], material: ["progress-activity"] }, + Check: { fluent: ["checkmark"], material: ["check"] }, + CheckCircle2: { fluent: ["checkmark-circle"], material: ["check-circle"] }, + CheckCircle: { fluent: ["checkmark-circle"], material: ["check-circle"] }, + X: { fluent: ["dismiss"], material: ["close"] }, + XCircle: { fluent: ["dismiss-circle"], material: ["cancel"] }, + AlertTriangle: { fluent: ["warning"], material: ["warning"] }, + TriangleAlert: { fluent: ["warning"], material: ["warning"] }, + AlertCircle: { fluent: ["error-circle"], material: ["error"] }, + Info: { fluent: ["info"], material: ["info"] }, + HelpCircle: { fluent: ["question-circle"], material: ["help"] }, + Bell: { fluent: ["alert"], material: ["notifications"] }, + Activity: { fluent: ["pulse"], material: ["monitoring", "timeline"] }, + + // ── Editing / actions ──────────────────────────────────────────────────── + Plus: { fluent: ["add"], material: ["add"] }, + Minus: { fluent: ["subtract"], material: ["remove"] }, + Trash2: { fluent: ["delete"], material: ["delete"] }, + Trash: { fluent: ["delete"], material: ["delete"] }, + Pencil: { fluent: ["edit"], material: ["edit"] }, + PenLine: { fluent: ["edit-line-horizontal-3", "edit"], material: ["edit"] }, + Edit: { fluent: ["edit"], material: ["edit"] }, + Save: { fluent: ["save"], material: ["save"] }, + Copy: { fluent: ["copy"], material: ["content-copy"] }, + Download: { fluent: ["arrow-download"], material: ["download"] }, + Upload: { fluent: ["arrow-upload"], material: ["upload"] }, + Undo2: { fluent: ["arrow-undo"], material: ["undo"] }, + RotateCcw: { fluent: ["arrow-counterclockwise", "arrow-undo"], material: ["undo", "refresh"] }, + RefreshCw: { fluent: ["arrow-sync"], material: ["refresh"] }, + RefreshCcw: { fluent: ["arrow-sync"], material: ["refresh"] }, + Search: { fluent: ["search"], material: ["search"] }, + Filter: { fluent: ["filter"], material: ["filter-alt"] }, + SlidersHorizontal: { fluent: ["options"], material: ["tune"] }, + Settings: { fluent: ["settings"], material: ["settings"] }, + Settings2: { fluent: ["settings"], material: ["tune"] }, + Share2: { fluent: ["share"], material: ["share"] }, + Send: { fluent: ["send"], material: ["send"] }, + Eye: { fluent: ["eye"], material: ["visibility"] }, + EyeOff: { fluent: ["eye-off"], material: ["visibility-off"] }, + + // ── Navigation / arrows ────────────────────────────────────────────────── + ChevronDown: { fluent: ["chevron-down"], material: ["keyboard-arrow-down"] }, + ChevronUp: { fluent: ["chevron-up"], material: ["keyboard-arrow-up"] }, + ChevronLeft: { fluent: ["chevron-left"], material: ["keyboard-arrow-left"] }, + ChevronRight: { fluent: ["chevron-right"], material: ["keyboard-arrow-right"] }, + ArrowLeft: { fluent: ["arrow-left"], material: ["arrow-back"] }, + ArrowRight: { fluent: ["arrow-right"], material: ["arrow-forward"] }, + ArrowUp: { fluent: ["arrow-up"], material: ["arrow-upward"] }, + ArrowDown: { fluent: ["arrow-down"], material: ["arrow-downward"] }, + CornerDownLeft: { fluent: ["arrow-enter-left", "arrow-enter"], material: ["subdirectory-arrow-left"] }, + ExternalLink: { fluent: ["open"], material: ["open-in-new"] }, + MoreHorizontal: { fluent: ["more-horizontal"], material: ["more-horiz"] }, + MoreVertical: { fluent: ["more-vertical"], material: ["more-vert"] }, + Menu: { fluent: ["navigation"], material: ["menu"] }, + PanelLeft: { fluent: ["panel-left"], material: ["view-sidebar"] }, + Maximize2: { fluent: ["arrow-expand"], material: ["open-in-full"] }, + Minimize2: { fluent: ["arrow-minimize"], material: ["close-fullscreen"] }, + + // ── Communication ──────────────────────────────────────────────────────── + Mail: { fluent: ["mail"], material: ["mail"] }, + MailOpen: { fluent: ["mail-read"], material: ["drafts"] }, + MailMinus: { fluent: ["mail-dismiss"], material: ["unsubscribe"] }, + Inbox: { fluent: ["mail-inbox"], material: ["inbox"] }, + Reply: { fluent: ["arrow-reply"], material: ["reply"] }, + ReplyAll: { fluent: ["arrow-reply-all"], material: ["reply-all"] }, + Forward: { fluent: ["arrow-forward"], material: ["forward"] }, + MessageCircle: { fluent: ["chat"], material: ["chat"] }, + MessageSquare: { fluent: ["chat"], material: ["chat-bubble"] }, + MessagesSquare: { fluent: ["chat-multiple"], material: ["forum"] }, + Phone: { fluent: ["call"], material: ["call"] }, + Paperclip: { fluent: ["attach"], material: ["attach-file"] }, + AtSign: { fluent: ["mention"], material: ["alternate-email"] }, + + // ── People ─────────────────────────────────────────────────────────────── + User: { fluent: ["person"], material: ["person"] }, + Users: { fluent: ["people"], material: ["group"] }, + UserPlus: { fluent: ["person-add"], material: ["person-add"] }, + UserMinus: { fluent: ["person-subtract"], material: ["person-remove"] }, + Building2: { fluent: ["building"], material: ["apartment"] }, + + // ── Files / content ────────────────────────────────────────────────────── + File: { fluent: ["document"], material: ["draft"] }, + FileText: { fluent: ["document-text"], material: ["description"] }, + FileCode: { fluent: ["document-javascript", "code"], material: ["code"] }, + FileSpreadsheet: { fluent: ["document-table"], material: ["table-chart"] }, + FileImage: { fluent: ["image"], material: ["image"] }, + Folder: { fluent: ["folder"], material: ["folder"] }, + FolderOpen: { fluent: ["folder-open"], material: ["folder-open"] }, + FolderInput: { fluent: ["folder-arrow-right"], material: ["drive-file-move"] }, + FolderKanban: { fluent: ["board"], material: ["view-kanban"] }, + Archive: { fluent: ["archive"], material: ["archive"] }, + ArchiveRestore: { fluent: ["archive-arrow-back"], material: ["unarchive"] }, + Image: { fluent: ["image"], material: ["image"] }, + BookOpen: { fluent: ["book-open"], material: ["menu-book"] }, + StickyNote: { fluent: ["note"], material: ["sticky-note-2"] }, + ClipboardCheck: { fluent: ["clipboard-checkmark"], material: ["assignment-turned-in"] }, + + // ── Layout ─────────────────────────────────────────────────────────────── + LayoutGrid: { fluent: ["grid"], material: ["grid-view"] }, + LayoutList: { fluent: ["apps-list"], material: ["view-list"] }, + LayoutDashboard: { fluent: ["board"], material: ["dashboard"] }, + Columns3: { fluent: ["column-triple"], material: ["view-column"] }, + ListChecks: { fluent: ["task-list-square-ltr"], material: ["checklist"] }, + ListTree: { fluent: ["text-bullet-list-tree"], material: ["account-tree"] }, + CheckSquare: { fluent: ["checkbox-checked"], material: ["check-box"] }, + Square: { fluent: ["square"], material: ["square"] }, + Circle: { fluent: ["circle"], material: ["circle"] }, + + // ── Time ───────────────────────────────────────────────────────────────── + Clock: { fluent: ["clock"], material: ["schedule"] }, + Timer: { fluent: ["timer"], material: ["timer"] }, + History: { fluent: ["history"], material: ["history"] }, + Calendar: { fluent: ["calendar"], material: ["calendar-today"] }, + CalendarDays: { fluent: ["calendar-ltr"], material: ["calendar-month"] }, + CalendarClock: { fluent: ["calendar-clock"], material: ["event-upcoming", "schedule"] }, + CalendarPlus: { fluent: ["calendar-add"], material: ["calendar-add-on"] }, + + // ── Media ──────────────────────────────────────────────────────────────── + Play: { fluent: ["play"], material: ["play-arrow"] }, + Pause: { fluent: ["pause"], material: ["pause"] }, + Mic: { fluent: ["mic"], material: ["mic"] }, + MicOff: { fluent: ["mic-off"], material: ["mic-off"] }, + Video: { fluent: ["video"], material: ["videocam"] }, + Camera: { fluent: ["camera"], material: ["photo-camera"] }, + Volume2: { fluent: ["speaker-2"], material: ["volume-up"] }, + Radio: { fluent: ["radio-button"], material: ["radio-button-checked"] }, + + // ── Systems / infra ────────────────────────────────────────────────────── + Zap: { fluent: ["flash"], material: ["bolt"] }, + Cloud: { fluent: ["cloud"], material: ["cloud"] }, + HardDrive: { fluent: ["hard-drive"], material: ["hard-drive", "storage"] }, + Server: { fluent: ["server"], material: ["dns"] }, + Database: { fluent: ["database"], material: ["database"] }, + Cpu: { fluent: ["developer-board"], material: ["memory"] }, + Terminal: { fluent: ["window-console", "code"], material: ["terminal"] }, + Code: { fluent: ["code"], material: ["code"] }, + GitBranch: { fluent: ["branch"], material: ["fork-right"] }, + Workflow: { fluent: ["flowchart"], material: ["account-tree"] }, + Plug: { fluent: ["plug-connected"], material: ["power"] }, + Power: { fluent: ["power"], material: ["power-settings-new"] }, + Puzzle: { fluent: ["puzzle-piece"], material: ["extension"] }, + Boxes: { fluent: ["cube-multiple", "cube"], material: ["widgets"] }, + Package: { fluent: ["box"], material: ["inventory-2"] }, + Layers: { fluent: ["layer"], material: ["layers"] }, + Globe: { fluent: ["globe"], material: ["public"] }, + Link2: { fluent: ["link"], material: ["link"] }, + Link: { fluent: ["link"], material: ["link"] }, + Wrench: { fluent: ["wrench"], material: ["build"] }, + FlaskConical: { fluent: ["beaker"], material: ["science"] }, + Rocket: { fluent: ["rocket"], material: ["rocket-launch"] }, + Bot: { fluent: ["bot"], material: ["smart-toy"] }, + Brain: { fluent: ["brain-circuit"], material: ["psychology"] }, + Sparkles: { fluent: ["sparkle"], material: ["auto-awesome"] }, + Wand2: { fluent: ["wand"], material: ["auto-fix-high"] }, + Lightbulb: { fluent: ["lightbulb"], material: ["lightbulb"] }, + Target: { fluent: ["target"], material: ["target", "my-location"] }, + TrendingUp: { fluent: ["arrow-trending-lines", "arrow-trending"], material: ["trending-up"] }, + BarChart3: { fluent: ["data-bar-vertical"], material: ["bar-chart"] }, + Waves: { fluent: ["wifi-1", "pulse"], material: ["waves"] }, + Wind: { fluent: ["weather-squalls"], material: ["air"] }, + + // ── Security ───────────────────────────────────────────────────────────── + Lock: { fluent: ["lock-closed"], material: ["lock"] }, + Unlock: { fluent: ["lock-open"], material: ["lock-open"] }, + KeyRound: { fluent: ["key"], material: ["key"] }, + Shield: { fluent: ["shield"], material: ["shield"] }, + ShieldCheck: { fluent: ["shield-checkmark"], material: ["verified-user"] }, + ShieldAlert: { fluent: ["shield-error"], material: ["gpp-maybe"] }, + ShieldOff: { fluent: ["shield-dismiss"], material: ["gpp-bad"] }, + LogOut: { fluent: ["sign-out"], material: ["logout"] }, + LogIn: { fluent: ["arrow-enter"], material: ["login"] }, + + // ── Misc chrome ────────────────────────────────────────────────────────── + Star: { fluent: ["star"], material: ["star"] }, + Flag: { fluent: ["flag"], material: ["flag"] }, + Tag: { fluent: ["tag"], material: ["label"] }, + Tags: { fluent: ["tag-multiple"], material: ["label"] }, + Sun: { fluent: ["weather-sunny"], material: ["light-mode"] }, + Moon: { fluent: ["weather-moon"], material: ["dark-mode"] }, + Monitor: { fluent: ["desktop"], material: ["desktop-windows"] }, + Smartphone: { fluent: ["phone"], material: ["smartphone"] }, + Home: { fluent: ["home"], material: ["home"] }, + Command: { fluent: ["keyboard-shift"], material: ["keyboard-command-key"] }, + MapPin: { fluent: ["location"], material: ["location-on"] }, + Briefcase: { fluent: ["briefcase"], material: ["work"] }, + CreditCard: { fluent: ["payment"], material: ["credit-card"] }, + DollarSign: { fluent: ["currency-dollar-euro", "currency"], material: ["attach-money"] }, + ThumbsUp: { fluent: ["thumb-like"], material: ["thumb-up"] }, + Stethoscope: { fluent: ["stethoscope"], material: ["stethoscope"] }, + + // ── Navigation icons (src/lib/nav.ts, src/lib/centers.ts) ──────────────── + // The sidebar is the most visible surface in the app, so every icon it can + // render needs a mapping or the theme switch looks half-applied. + Palette: { fluent: ["color"], material: ["palette"] }, + UserCog: { fluent: ["person-settings"], material: ["manage-accounts"] }, + UserSearch: { fluent: ["person-search"], material: ["person-search"] }, + PlusSquare: { fluent: ["add-square"], material: ["add-box"] }, + KanbanSquare: { fluent: ["board"], material: ["view-kanban"] }, + ClipboardList: { fluent: ["clipboard-task-list-ltr"], material: ["assignment"] }, + Cog: { fluent: ["settings"], material: ["settings"] }, + Network: { fluent: ["organization"], material: ["hub"] }, + BellRing: { fluent: ["alert-badge", "alert-on"], material: ["notifications-active"] }, + Megaphone: { fluent: ["megaphone"], material: ["campaign"] }, + Newspaper: { fluent: ["news"], material: ["newspaper"] }, + Handshake: { fluent: ["handshake"], material: ["handshake"] }, + Landmark: { fluent: ["building-bank"], material: ["account-balance"] }, + Receipt: { fluent: ["receipt"], material: ["receipt"] }, + Wallet: { fluent: ["wallet"], material: ["wallet"] }, + ShoppingCart: { fluent: ["cart"], material: ["shopping-cart"] }, + Truck: { fluent: ["vehicle-truck"], material: ["local-shipping"] }, + Factory: { fluent: ["building-factory"], material: ["factory"] }, + LifeBuoy: { fluent: ["person-support"], material: ["support"] }, + + // ── Rich-text editor toolbar (SignatureEditor, notes) ──────────────────── + Bold: { fluent: ["text-bold"], material: ["format-bold"] }, + Italic: { fluent: ["text-italic"], material: ["format-italic"] }, + Underline: { fluent: ["text-underline"], material: ["format-underlined"] }, + Strikethrough: { fluent: ["text-strikethrough"], material: ["format-strikethrough"] }, + RemoveFormatting: { fluent: ["text-clear-formatting"], material: ["format-clear"] }, + AlignLeft: { fluent: ["text-align-left"], material: ["format-align-left"] }, + AlignCenter: { fluent: ["text-align-center"], material: ["format-align-center"] }, + AlignRight: { fluent: ["text-align-right"], material: ["format-align-right"] }, + List: { fluent: ["text-bullet-list-ltr"], material: ["format-list-bulleted"] }, + ListOrdered: { fluent: ["text-number-list-ltr"], material: ["format-list-numbered"] }, + Table: { fluent: ["table"], material: ["table"] }, + Redo2: { fluent: ["arrow-redo"], material: ["redo"] }, + Link2Off: { fluent: ["link-dismiss"], material: ["link-off"] }, + ImageOff: { fluent: ["image-off", "image-prohibited"], material: ["hide-image"] }, + Eraser: { fluent: ["eraser"], material: ["ink-eraser"] }, + PenTool: { fluent: ["pen"], material: ["draw"] }, + PencilLine: { fluent: ["edit"], material: ["edit"] }, + SquarePen: { fluent: ["edit"], material: ["edit-square"] }, + Code2: { fluent: ["code"], material: ["code"] }, + + // ── Panels / layout affordances ────────────────────────────────────────── + PanelLeftClose: { fluent: ["panel-left-contract"], material: ["left-panel-close"] }, + PanelLeftOpen: { fluent: ["panel-left-expand"], material: ["left-panel-open"] }, + PanelRight: { fluent: ["panel-right"], material: ["view-sidebar"] }, + Columns2: { fluent: ["column-double-compare", "column"], material: ["view-column-2", "view-column"] }, + Rows3: { fluent: ["row-triple"], material: ["table-rows"] }, + LayoutTemplate: { fluent: ["slide-layout"], material: ["dashboard"] }, + AppWindow: { fluent: ["window"], material: ["web-asset"] }, + GripVertical: { fluent: ["re-order-dots-vertical"], material: ["drag-indicator"] }, + StretchHorizontal: { fluent: ["auto-fit-width", "arrow-expand"], material: ["width-full", "height"] }, + ChevronsUpDown: { fluent: ["chevron-up-down"], material: ["unfold-more"] }, + + // ── Sorting / filtering ────────────────────────────────────────────────── + ListFilter: { fluent: ["filter"], material: ["filter-list"] }, + ArrowDownUp: { fluent: ["arrow-sort"], material: ["swap-vert"] }, + ArrowUpNarrowWide: { fluent: ["arrow-sort-up"], material: ["sort"] }, + ArrowDownWideNarrow: { fluent: ["arrow-sort-down"], material: ["sort"] }, + SearchX: { fluent: ["search-info", "dismiss-circle"], material: ["search-off"] }, + + // ── Arrows / movement ──────────────────────────────────────────────────── + ArrowUpRight: { fluent: ["arrow-up-right"], material: ["north-east"] }, + ArrowDownRight: { fluent: ["arrow-down-right"], material: ["south-east"] }, + ArrowUpToLine: { fluent: ["arrow-export-up", "arrow-up"], material: ["vertical-align-top"] }, + CornerDownRight: { fluent: ["arrow-turn-right-down", "arrow-enter"], material: ["subdirectory-arrow-right"] }, + SkipForward: { fluent: ["next"], material: ["skip-next"] }, + RotateCw: { fluent: ["arrow-clockwise"], material: ["refresh"] }, + + // ── Status / progress ──────────────────────────────────────────────────── + LoaderCircle: { fluent: ["spinner-ios"], material: ["progress-activity"] }, + Hourglass: { fluent: ["hourglass"], material: ["hourglass-empty"] }, + CheckCheck: { fluent: ["checkmark-starburst", "checkmark"], material: ["done-all"] }, + BadgeCheck: { fluent: ["checkmark-starburst"], material: ["verified"] }, + CircleDot: { fluent: ["radio-button"], material: ["radio-button-checked"] }, + MinusCircle: { fluent: ["subtract-circle"], material: ["do-not-disturb-on"] }, + PauseCircle: { fluent: ["pause-circle"], material: ["pause-circle"] }, + Gauge: { fluent: ["gauge"], material: ["speed"] }, + Milestone: { fluent: ["flag"], material: ["flag"] }, + ShieldX: { fluent: ["shield-dismiss"], material: ["gpp-bad"] }, + + // ── Lists / tasks ──────────────────────────────────────────────────────── + ListTodo: { fluent: ["task-list-square-ltr"], material: ["checklist"] }, + ListPlus: { fluent: ["text-bullet-list-add"], material: ["playlist-add"] }, + CopyX: { fluent: ["clipboard-error", "copy"], material: ["content-paste-off"] }, + FolderPlus: { fluent: ["folder-add"], material: ["create-new-folder"] }, + FolderClosed: { fluent: ["folder"], material: ["folder"] }, + BookMarked: { fluent: ["book-star", "bookmark"], material: ["bookmark"] }, + Pin: { fluent: ["pin"], material: ["push-pin"] }, + PinOff: { fluent: ["pin-off"], material: ["keep-off"] }, + + // ── Comms / calls ──────────────────────────────────────────────────────── + Mails: { fluent: ["mail-multiple"], material: ["mail"] }, + MessageSquareText: { fluent: ["comment-text", "chat"], material: ["chat"] }, + MessageCircleQuestion: { fluent: ["chat-help"], material: ["contact-support"] }, + PhoneIncoming: { fluent: ["call-inbound"], material: ["phone-callback"] }, + PhoneOff: { fluent: ["call-dismiss"], material: ["phone-disabled"] }, + Headphones: { fluent: ["headphones"], material: ["headphones"] }, + AudioLines: { fluent: ["sound-wave-circle", "speaker-2"], material: ["graphic-eq"] }, + + // ── Misc ───────────────────────────────────────────────────────────────── + UserRound: { fluent: ["person"], material: ["person"] }, + Keyboard: { fluent: ["keyboard"], material: ["keyboard"] }, + Printer: { fluent: ["print"], material: ["print"] }, + QrCode: { fluent: ["qr-code"], material: ["qr-code"] }, + Webhook: { fluent: ["link-square", "plug-connected"], material: ["webhook"] }, + GitFork: { fluent: ["branch-fork"], material: ["fork-right"] }, + Hammer: { fluent: ["hammer", "wrench"], material: ["hardware", "build"] }, + Box: { fluent: ["box"], material: ["inventory-2"] }, + Sliders: { fluent: ["options"], material: ["tune"] }, + BarChart2: { fluent: ["data-bar-vertical"], material: ["bar-chart"] }, + UploadCloud: { fluent: ["cloud-arrow-up"], material: ["cloud-upload"] }, + Coins: { fluent: ["money"], material: ["payments"] }, + Gem: { fluent: ["diamond"], material: ["diamond"] }, + Coffee: { fluent: ["drink-coffee"], material: ["coffee"] }, + Footprints: { fluent: ["shoe-print", "run"], material: ["footprint", "directions-walk"] }, + Mountain: { fluent: ["mountain-location-top", "mountain-trail"], material: ["landscape"] }, + DoorOpen: { fluent: ["door"], material: ["door-open"] }, + MoonStar: { fluent: ["weather-moon"], material: ["bedtime"] }, + CalendarX: { fluent: ["calendar-cancel"], material: ["event-busy"] }, + AlarmClockOff: { fluent: ["alert-off"], material: ["alarm-off"] }, + Battery: { fluent: ["battery-10"], material: ["battery-full"] }, + BatteryLow: { fluent: ["battery-2"], material: ["battery-2-bar"] }, + BatteryMedium: { fluent: ["battery-5"], material: ["battery-5-bar"] }, +}; + +/** + * Suffixes tried against each candidate base name. + * + * Fluent's UI standard is the 20px regular grid, with 24px as the common + * fallback. Material Symbols Rounded is the face Material 3 actually ships, + * so rounded variants come first. + */ +const SUFFIXES = { + fluent: ["-20-regular", "-24-regular", "-16-regular", "-28-regular", "-32-regular", "-48-regular"], + material: ["-rounded", "", "-outline-rounded", "-outline"], +}; + +const COLLECTIONS = { + fluent: JSON.parse( + readFileSync(resolve(ROOT, "node_modules/@iconify-json/fluent/icons.json"), "utf8"), + ), + material: JSON.parse( + readFileSync( + resolve(ROOT, "node_modules/@iconify-json/material-symbols/icons.json"), + "utf8", + ), + ), +}; + +/** Iconify prefix each pack publishes under. */ +const PREFIX = { fluent: "fluent", material: "material-symbols" }; + +function exists(collection, name) { + return Boolean(collection.icons?.[name] || collection.aliases?.[name]); +} + +/** First `base + suffix` combination that exists in the collection. */ +function resolveName(pack, bases) { + const collection = COLLECTIONS[pack]; + for (const base of bases) { + for (const suffix of SUFFIXES[pack]) { + const candidate = `${base}${suffix}`; + if (exists(collection, candidate)) return candidate; + } + } + return null; +} + +const registry = {}; +const missing = []; + +for (const [lucideName, candidates] of Object.entries(MAP)) { + const entry = {}; + for (const pack of ["fluent", "material"]) { + const resolved = resolveName(pack, candidates[pack] ?? []); + if (resolved) entry[pack] = resolved; + else missing.push(`${lucideName} → ${pack} (tried: ${(candidates[pack] ?? []).join(", ")})`); + } + if (Object.keys(entry).length > 0) registry[lucideName] = entry; +} + +mkdirSync(OUT_DIR, { recursive: true }); + +// Pruned collections — only the icons the registry actually references. +// getIcons() follows aliases and parent chains, so the output is self-contained. +const summary = []; +for (const pack of ["fluent", "material"]) { + const names = Object.values(registry) + .map((e) => e[pack]) + .filter(Boolean); + const pruned = getIcons(COLLECTIONS[pack], names); + if (!pruned) throw new Error(`Failed to prune the ${pack} collection`); + pruned.prefix = PREFIX[pack]; + const file = resolve(OUT_DIR, `${pack}.json`); + const json = JSON.stringify(pruned); + writeFileSync(file, `${json}\n`); + summary.push( + ` ${pack.padEnd(9)} ${String(names.length).padStart(3)} icons ${(json.length / 1024).toFixed(1)} KB`, + ); +} + +writeFileSync( + resolve(OUT_DIR, "registry.json"), + `${JSON.stringify(registry, null, 2)}\n`, +); + +console.log(`Wrote ${Object.keys(registry).length} icon mappings to ${OUT_DIR}`); +console.log(summary.join("\n")); + +if (missing.length > 0) { + console.error(`\n${missing.length} unresolved mapping(s):`); + for (const m of missing) console.error(` ${m}`); + process.exitCode = 1; +} diff --git a/workbench/control_plane/src/app/agents/page.tsx b/workbench/control_plane/src/app/agents/page.tsx index 88560ab82..408f6b9b6 100644 --- a/workbench/control_plane/src/app/agents/page.tsx +++ b/workbench/control_plane/src/app/agents/page.tsx @@ -12,28 +12,10 @@ * 3. Register → agent appears in picker on the Chat page */ +import Button from "@/components/ui/Button"; +import AppIcon from "@/components/Icon"; import React, { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; -import { - AlertTriangle, - Bot, - CheckSquare, - ChevronRight, - ExternalLink, - Filter, - FolderOpen, - Lightbulb, - Loader2, - MessageCircle, - Package, - Plug, - Plus, - Receipt, - RefreshCw, - Trash2, - TrendingUp, - X, -} from "lucide-react"; import type { AgentEntry } from "@/app/api/agent/list/route"; import type { MutationEntry } from "@/app/api/agent/mutations/route"; import type { IntegrationStatus } from "@/app/api/integrations/status/route"; @@ -201,10 +183,7 @@ function PendingCommits({ agentName }: { agentName: string }) { return (
- + {open && (
@@ -586,10 +565,7 @@ function AgentSkillsPanel({ agentName }: { agentName: string }) { return (
- + {open && (
@@ -687,13 +663,9 @@ function AgentSkillsPanel({ agentName }: { agentName: string }) { className="w-full rounded-lg border border-border bg-secondary px-3 py-1.5 text-xs text-foreground placeholder-muted-foreground focus:border-primary focus:outline-none" />
- +
- - + +
)} @@ -1210,12 +1174,9 @@ function AddAgentModal({ {errorMsg}
- +
)} @@ -1239,15 +1200,15 @@ function GithubIcon({ size = 16, className = "" }: { size?: number; className?: // Agent icons + colors by name // --------------------------------------------------------------------------- -const AGENT_ICONS: Record = { - "task-manager": CheckSquare, - "sales": TrendingUp, - "delivery": Package, - "triage": Filter, - "reconciler": RefreshCw, - "billing": Receipt, - "strategy": Lightbulb, - "apis-config": Plug, +const AGENT_ICONS: Record = { + "task-manager": "CheckSquare", + "sales": "TrendingUp", + "delivery": "Package", + "triage": "Filter", + "reconciler": "RefreshCw", + "billing": "Receipt", + "strategy": "Lightbulb", + "apis-config": "Plug", }; const AGENT_COLORS: Record = { @@ -1261,8 +1222,8 @@ const AGENT_COLORS: Record = { "apis-config": "text-primary", }; -function getAgentIcon(agent: AgentEntry): React.ElementType { - return AGENT_ICONS[agent.name] ?? Bot; +function getAgentIcon(agent: AgentEntry): string { + return AGENT_ICONS[agent.name] ?? "Bot"; } function getAgentColor(agent: AgentEntry): string { @@ -1303,7 +1264,7 @@ function AgentTile({ onRefresh?: () => void; avatarLibraryId?: string | null; }) { - const Icon = getAgentIcon(agent); + const iconName = getAgentIcon(agent); const color = getAgentColor(agent); const readiness = agentReadiness(agent, statuses); const behindBy = (agent as any).behind_by as number | undefined; @@ -1375,7 +1336,7 @@ function AgentTile({ } + fallback={} /> } /> @@ -1538,7 +1499,7 @@ function AgentAvatarPicker({ agentName }: { agentName: string }) { {current ? ( ) : ( - + )} @@ -1553,7 +1514,7 @@ function AgentAvatarPicker({ agentName }: { agentName: string }) { Tap to choose from the library - + {/* Popup picker — bottom sheet on mobile, centered dialog on desktop */} @@ -1575,7 +1536,7 @@ function AgentAvatarPicker({ agentName }: { agentName: string }) { onClick={() => setOpen(false)} className="rounded-md p-1 text-muted-foreground hover:bg-secondary transition-colors" > - +
@@ -1612,7 +1573,7 @@ function AgentAvatarPicker({ agentName }: { agentName: string }) { style={{ width: 104, height: 104 }} className={`${tileCls(libraryId === null)} flex-col gap-1 disabled:opacity-60`} > - + Default {shown.map((c) => ( @@ -1664,7 +1625,7 @@ function AgentSidePanel({ compact?: boolean; avatarLibraryId?: string | null; }) { - const Icon = getAgentIcon(agent); + const iconName = getAgentIcon(agent); const color = getAgentColor(agent); const [confirming, setConfirming] = useState(false); const [removing, setRemoving] = useState(false); @@ -1793,7 +1754,7 @@ function AgentSidePanel({ } + fallback={} /> } /> @@ -1822,7 +1783,7 @@ function AgentSidePanel({
)} @@ -1874,7 +1835,7 @@ function AgentSidePanel({ {missingDeps.length > 0 && (
- +
Agent blocked — needs{" "} @@ -1886,7 +1847,7 @@ function AgentSidePanel({ {agent.dep_status?.ok === false && (
- +
Dependencies failed to install {(agent.dep_status.needs_system_packages?.length ?? 0) > 0 ? ( @@ -1928,7 +1889,7 @@ function AgentSidePanel({ state === "ok" ? "bg-success" : state === "missing" ? "bg-warning" : "bg-muted" }`} /> {intg?.label ?? i} - {state === "missing" && } + {state === "missing" && } ); @@ -1964,7 +1925,7 @@ function AgentSidePanel({
Source
{agent.local_path ? (
- + {agent.local_path}
) : ( @@ -1980,7 +1941,7 @@ function AgentSidePanel({ > {agent.repo_name ?? agent.repo_url} - + )}
@@ -2000,14 +1961,9 @@ function AgentSidePanel({ Up to date - +
)} @@ -2018,7 +1974,7 @@ function AgentSidePanel({ disabled={pulling} className="flex items-center justify-center gap-2 w-full rounded-lg bg-amber-500/10 border border-amber-500/20 px-3 py-2 text-xs font-medium text-amber-600 hover:bg-amber-500/20 disabled:opacity-50 transition-colors" > - + {pulling ? "Pulling…" : `Pull ${behindBy} update${behindBy !== 1 ? "s" : ""}`} @@ -2028,7 +1984,7 @@ function AgentSidePanel({ {/* Pulling spinner */} {pulling && (
- + Pulling latest commits…
)} @@ -2106,7 +2062,7 @@ function AgentSidePanel({ href={`/chat?agent=${encodeURIComponent(agent.name)}`} className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-primary hover:opacity-90 text-sm font-medium text-primary-foreground transition-colors" > - Chat + Chat {agent.dynamic && ( confirming ? ( @@ -2124,7 +2080,7 @@ function AgentSidePanel({ ) )} @@ -2242,7 +2198,7 @@ export default function AgentsPage() { : "Check all for updates" } > - + {checkingAll ? "Checking…" @@ -2254,12 +2210,11 @@ export default function AgentsPage() { )} - +
@@ -2274,13 +2229,13 @@ export default function AgentsPage() {
{loading ? (
- Loading agents… + Loading agents…
) : filtered.length === 0 ? (

No {filter !== "all" ? filter : ""} agents found.

) : ( @@ -2300,7 +2255,7 @@ export default function AgentsPage() { onClick={() => setShowAdd(true)} className="p-4 rounded-xl border-2 border-dashed border-border text-muted-foreground hover:border-primary/40 hover:text-primary hover:bg-primary/5 transition-all flex flex-col items-center justify-center gap-2 min-h-[120px]" > - Add Agent + Add Agent
)} @@ -2315,20 +2270,20 @@ export default function AgentsPage() {
{(() => { - const Icon = getAgentIcon(selectedAgent); + const iconName = getAgentIcon(selectedAgent); const color = getAgentColor(selectedAgent); return ( } + fallback={} /> ); })()} {selectedAgent.display_name || selectedAgent.name}
diff --git a/workbench/control_plane/src/app/api/settings/appearance/route.ts b/workbench/control_plane/src/app/api/settings/appearance/route.ts new file mode 100644 index 000000000..dad036498 --- /dev/null +++ b/workbench/control_plane/src/app/api/settings/appearance/route.ts @@ -0,0 +1,168 @@ +/** + * GET /api/settings/appearance — the organisation's appearance defaults + * PUT /api/settings/appearance — update them (admin-only, enforced upstream) + * + * Only the ORGANISATION half lives here. A member's own theme, density and + * accent stay in their browser: they are per-device preferences with no + * server-side consequence, and round-tripping them would add a request to + * every page load to render something the boot script has already applied. + * + * The gateway serves the org half from the `org_settings` table (migration + * 145). A deployment that has not applied that migration, or whose gateway is + * down, still has to render: GET falls back to the built-in defaults and + * reports `orgManaged: false`, which is what Settings uses to explain that the + * org-wide controls are unavailable rather than silently accepting edits that + * cannot be saved. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { + GATEWAY_URL, + NoIdentityError, + gatewayHeaders, + requireIdentity, + unauthenticated, +} from "@/lib/gateway"; +import { DEFAULT_THEME_ID, findTheme } from "@/lib/theme/themes"; +import { isSafeColor } from "@/lib/theme/css"; +import { DENSITY_SCALE } from "@/lib/theme/types"; +import type { AppearanceSettings, Density, ThemeMode } from "@/lib/theme/types"; + +export const dynamic = "force-dynamic"; + +const GATEWAY_PATH = "/settings/appearance"; + +const BUILTIN_DEFAULTS: AppearanceSettings["org"] = { + themeId: DEFAULT_THEME_ID, + mode: "dark", + density: "default", + allowUserOverride: true, +}; + +const EMPTY_USER: AppearanceSettings["user"] = { + themeId: null, + mode: null, + density: null, + accent: null, +}; + +function isMode(v: unknown): v is ThemeMode { + return v === "dark" || v === "light"; +} + +function isDensity(v: unknown): v is Density { + return typeof v === "string" && v in DENSITY_SCALE; +} + +/** + * Coerce whatever the gateway returned into a usable org default. + * + * Every field is validated rather than trusted: a theme id that no longer + * exists (removed, renamed, or from a newer deployment) would otherwise put + * `data-theme` into a state no stylesheet matches, leaving the app unstyled. + * Unrecognised values fall back field by field, so one bad key does not + * discard an otherwise good response. + */ +function normaliseOrg(raw: unknown): AppearanceSettings["org"] { + const o = (raw ?? {}) as Record; + return { + themeId: findTheme(String(o.themeId ?? "")) ? String(o.themeId) : BUILTIN_DEFAULTS.themeId, + mode: isMode(o.mode) ? o.mode : BUILTIN_DEFAULTS.mode, + density: isDensity(o.density) ? o.density : BUILTIN_DEFAULTS.density, + allowUserOverride: + typeof o.allowUserOverride === "boolean" + ? o.allowUserOverride + : BUILTIN_DEFAULTS.allowUserOverride, + }; +} + +export async function GET(): Promise { + const me = await requireIdentity(); + if (me instanceof NextResponse) return me; + + try { + const res = await fetch(`${GATEWAY_URL}${GATEWAY_PATH}`, { + headers: await gatewayHeaders(), + signal: AbortSignal.timeout(8_000), + cache: "no-store", + }); + if (res.ok) { + const data = await res.json(); + return NextResponse.json({ + org: normaliseOrg(data?.org ?? data), + user: EMPTY_USER, + orgManaged: true, + updatedBy: typeof data?.updatedBy === "string" ? data.updatedBy : "", + updatedAt: typeof data?.updatedAt === "string" ? data.updatedAt : "", + } satisfies AppearanceSettings); + } + } catch { + // Gateway unreachable or timed out — fall through to the built-in default. + } + + return NextResponse.json({ + org: BUILTIN_DEFAULTS, + user: EMPTY_USER, + orgManaged: false, + } satisfies AppearanceSettings); +} + +export async function PUT(req: NextRequest): Promise { + const me = await requireIdentity(); + if (me instanceof NextResponse) return me; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Body must be JSON" }, { status: 400 }); + } + + const input = (body ?? {}) as Record; + + // Validate here rather than forwarding blindly: these values end up driving + // a CSS selector and a colour declaration for every member of the org, so a + // bad write is a broken app for everyone, not just the admin who made it. + if (input.themeId !== undefined && !findTheme(String(input.themeId))) { + return NextResponse.json({ error: `Unknown theme: ${String(input.themeId)}` }, { status: 400 }); + } + if (input.mode !== undefined && !isMode(input.mode)) { + return NextResponse.json({ error: "mode must be 'dark' or 'light'" }, { status: 400 }); + } + if (input.density !== undefined && !isDensity(input.density)) { + return NextResponse.json( + { error: `density must be one of ${Object.keys(DENSITY_SCALE).join(", ")}` }, + { status: 400 }, + ); + } + if ( + input.accent !== undefined && + input.accent !== null && + !isSafeColor(String(input.accent)) + ) { + return NextResponse.json({ error: "accent must be a plain CSS colour" }, { status: 400 }); + } + + // Whether the caller may set an org-wide default is an authorization + // question, and authorization is resolved from the org tables at the + // gateway — not from anything this request could assert. + try { + const res = await fetch(`${GATEWAY_URL}${GATEWAY_PATH}`, { + method: "PUT", + headers: await gatewayHeaders({ "Content-Type": "application/json" }), + body: JSON.stringify(input), + signal: AbortSignal.timeout(8_000), + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" }, + }); + } catch (err) { + if (err instanceof NoIdentityError) return unauthenticated(); + return NextResponse.json( + { error: "Appearance defaults are not available in this deployment" }, + { status: 503 }, + ); + } +} diff --git a/workbench/control_plane/src/app/approvals/page.tsx b/workbench/control_plane/src/app/approvals/page.tsx index 8eff9e3f1..a978319a1 100644 --- a/workbench/control_plane/src/app/approvals/page.tsx +++ b/workbench/control_plane/src/app/approvals/page.tsx @@ -15,6 +15,7 @@ * write auto-applies through the broker (chokepointed + audited, no hold). */ +import Button from "@/components/ui/Button"; import { useCallback, useEffect, useState } from "react"; import type { PendingAction } from "@/app/api/actions/pending/route"; @@ -87,12 +88,9 @@ export default function ApprovalsPage() { {rows.length} )} - +

Outward writes an agent proposed through the Action Broker. Approving diff --git a/workbench/control_plane/src/app/artifacts/page.tsx b/workbench/control_plane/src/app/artifacts/page.tsx index eb5c8aa66..fbbeddfe8 100644 --- a/workbench/control_plane/src/app/artifacts/page.tsx +++ b/workbench/control_plane/src/app/artifacts/page.tsx @@ -9,27 +9,9 @@ * Grid / List views change how the current directory's contents appear. */ +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { - Search, - Download, - Eye, - FolderOpen, - FolderClosed, - File, - FileCode, - FileText, - FileImage, - FileSpreadsheet, - X, - LayoutGrid, - List, - ChevronDown, - ChevronRight, - Sparkles, - RefreshCw, - Bot, -} from "lucide-react"; import ArtifactViewerModal from "@/components/ArtifactViewerModal"; import type { FileEntry } from "@/components/ArtifactSidebar"; @@ -101,23 +83,23 @@ function formatRelative(iso: string): string { function fileIconEl(entry: ArtifactEntry | { name: string; mime_type: string; is_dir?: boolean }, size = 16) { if ((entry as ArtifactEntry).is_dir) { - return ; + return ; } const ext = entry.name.split(".").pop()?.toLowerCase() ?? ""; const mime = entry.mime_type; if (["png","jpg","jpeg","gif","webp","svg","ico"].includes(ext) || mime.startsWith("image/")) - return ; + return ; if (["py","ts","tsx","js","jsx","sh","yaml","yml","toml","json","sql","rs","go","java"].includes(ext)) - return ; + return ; if (["md","txt","log","rst"].includes(ext) || mime.startsWith("text/")) - return ; + return ; if (["pdf"].includes(ext) || mime === "application/pdf") - return ; + return ; if (["docx","doc"].includes(ext)) - return ; + return ; if (["xlsx","xls","csv"].includes(ext)) - return ; - return ; + return ; + return ; } function isImage(entry: ArtifactEntry): boolean { @@ -237,7 +219,7 @@ function FileCard({ artifact, onView, index }: { artifact: ArtifactEntry; onView

@@ -255,7 +237,7 @@ function FolderCard({ item, onNavigate, index }: { item: ExplorerItem; onNavigat title={`${item.name}\nClick to open`} >
- +

{item.name}

@@ -278,7 +260,7 @@ function ListRow({ item, onNavigate, onView, index }: { >
{item.isDir - ? + ? : fileIconEl(item.entry ?? { name: item.name, mime_type: "" }, 15)}
{item.name}
@@ -422,7 +404,7 @@ export default function ArtifactsPage() {
- +

Artifacts

@@ -431,9 +413,9 @@ export default function ArtifactsPage() {

- +
{/* Stats */} @@ -471,17 +453,17 @@ export default function ArtifactsPage() {
- + setSearchQuery(e.target.value)} placeholder="Search files…" className="w-full rounded-lg border border-border bg-secondary pl-8 pr-8 py-1.5 text-[11px] text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" /> {searchQuery && ( - + )}
{hasFilters && ( )}
@@ -491,14 +473,14 @@ export default function ArtifactsPage() {
{loading && (
- +

Loading artifacts…

)} {error && !loading && (
-
+

{error}

@@ -506,7 +488,7 @@ export default function ArtifactsPage() { {!loading && !error && filteredFiles.length === 0 && availableAgents.length === 0 && (
- +

No artifacts yet

Files appear here as agents create them.

@@ -530,8 +512,8 @@ export default function ArtifactsPage() { }} className={`w-full flex items-center gap-3 px-4 py-3 bg-card hover:bg-secondary/40 tech-transition text-left border-l-2 ${accent}`} > - {isOpen ? : } - + {isOpen ? : } +
{name}
{agentFiles.length} file{agentFiles.length !== 1 ? "s" : ""}
@@ -549,7 +531,7 @@ export default function ArtifactsPage() { {currentPath && currentPath.split("/").map((seg, i, arr) => ( - + {i === arr.length - 1 ? ( {seg} ) : ( @@ -568,10 +550,10 @@ export default function ArtifactsPage() {
+ title="Grid view"> + title="List view">
@@ -579,7 +561,7 @@ export default function ArtifactsPage() {
{items.length === 0 ? (
- +

This folder is empty

) : viewMode === "grid" ? ( diff --git a/workbench/control_plane/src/app/build/apps/[slug]/edit/page.tsx b/workbench/control_plane/src/app/build/apps/[slug]/edit/page.tsx index 0078d1973..4a010243b 100644 --- a/workbench/control_plane/src/app/build/apps/[slug]/edit/page.tsx +++ b/workbench/control_plane/src/app/build/apps/[slug]/edit/page.tsx @@ -11,6 +11,8 @@ * (onActivity → debounce → refetch + POST /sync), and a fallback poll. */ +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { Suspense, use, @@ -21,36 +23,8 @@ import { useState, } from "react"; import { useRouter, useSearchParams } from "next/navigation"; -import { useTheme } from "next-themes"; +import { useMonacoTheme } from "@/lib/theme/surfaces"; import Editor from "@monaco-editor/react"; -import { - AlertTriangle, - ArrowLeft, - CheckCircle2, - ChevronDown, - ChevronRight, - Clock, - FileCode, - FlaskConical, - Folder, - FolderOpen, - History, - Loader2, - Lock, - Monitor, - Play, - Plug, - Plus, - RefreshCw, - Rocket, - Save, - Smartphone, - Sparkles, - Trash2, - Wrench, - X, - XCircle, -} from "lucide-react"; import AgentChat from "@/components/AgentChat"; import SandboxedHtml from "@/components/SandboxedHtml"; import Tabs from "@/components/Tabs"; @@ -70,7 +44,6 @@ import { type CcToolConfirmDecision, type CcToolConfirmRequest, } from "../../lib/ccBridge"; -import { buildIconMap } from "@/lib/iconSvg"; import { runAllScenarios, type TestResult, type TestScenario } from "../../lib/testRunner"; import type { AppFile, AppMeta, Checkpoint, GrantEntry } from "../../lib/types"; @@ -132,7 +105,7 @@ function CheckpointsPanel({ if (checkpoints === null) { return (
- +
); } @@ -165,12 +138,9 @@ function CheckpointsPanel({ Current ) : confirmSha !== c.sha ? ( - + ) : null}
{confirmSha === c.sha && ( @@ -178,16 +148,12 @@ function CheckpointsPanel({ Restore this checkpoint? - + @@ -357,7 +323,7 @@ function FileTreeView({ title={`${f.path} · ${formatBytes(f.size)}`} className="flex-1 min-w-0 flex items-center gap-2 px-2 py-1.5 text-left font-mono text-[11.5px]" > - + {f.path.split("/").pop()}
))} @@ -633,7 +599,7 @@ function PublishModal({ >
- +

@@ -644,12 +610,9 @@ function PublishModal({ the Workshop meanwhile.

- +
); @@ -665,7 +628,7 @@ function PublishModal({ >
- +

@@ -675,12 +638,9 @@ function PublishModal({ {inviteWarning}

- +
); @@ -771,14 +731,9 @@ function PublishModal({ className="inline-flex items-center gap-1 rounded-full border border-border bg-secondary px-2.5 py-1 text-xs text-foreground" > {email} - + ))}
@@ -809,9 +764,9 @@ function PublishModal({ }`} > {testStatus.passed === testStatus.total ? ( - + ) : ( - + )} {testStatus.passed === testStatus.total @@ -827,29 +782,22 @@ function PublishModal({ {error && (
- {error} + {error}
)}
- - + +
@@ -861,8 +809,7 @@ function PublishModal({ function Workshop({ slug }: { slug: string }) { const router = useRouter(); const searchParams = useSearchParams(); - const { resolvedTheme } = useTheme(); - const theme: "light" | "dark" = resolvedTheme === "light" ? "light" : "dark"; + const monacoTheme = useMonacoTheme(); const [app, setApp] = useState(null); const [loadError, setLoadError] = useState(null); @@ -1650,7 +1597,7 @@ function Workshop({ slug }: { slug: string }) { // into inline SVG here, same mechanism the chat-artifacts renderer already // uses for generative UI (GenerativeUINode.tsx). const previewIcons = useMemo( - () => (srcDoc ? buildIconMap(extractCcIconNames(srcDoc)) : {}), + () => (srcDoc ? extractCcIconNames(srcDoc) : []), [srcDoc] ); @@ -1659,12 +1606,9 @@ function Workshop({ slug }: { slug: string }) { return (

{loadError}

- +
); } @@ -1672,7 +1616,7 @@ function Workshop({ slug }: { slug: string }) { if (!app) { return (
- +

Opening Workshop…

); @@ -1682,12 +1626,9 @@ function Workshop({ slug }: { slug: string }) {
{/* ── Topbar ──────────────────────────────────────────────────── */}
- +
{app.icon || "▦"} @@ -1711,7 +1652,7 @@ function Workshop({ slug }: { slug: string }) { ? [ { id: "preview", label: "Preview" }, { id: "code", label: "Code" }, - { id: "tests", label: "Tests", icon: FlaskConical }, + { id: "tests", label: "Tests", icon: "FlaskConical" }, ] : [{ id: "preview", label: "Preview" }] } @@ -1740,7 +1681,7 @@ function Workshop({ slug }: { slug: string }) { : "text-muted-foreground hover:bg-secondary" }`} > - + {advanced ? "Advanced" : "Simple"} @@ -1757,7 +1698,7 @@ function Workshop({ slug }: { slug: string }) { : "text-muted-foreground hover:bg-secondary" }`} > - + {/* Desktop: a popover anchored to this button. On mobile this @@ -1827,12 +1768,9 @@ function Workshop({ slug }: { slug: string }) { )} - +
{/* ── Split main ──────────────────────────────────────────────── */} @@ -1856,7 +1794,7 @@ function Workshop({ slug }: { slug: string }) { title="Reload preview" className="p-1.5 rounded-lg border border-border text-muted-foreground hover:bg-secondary tech-transition" > - @@ -1882,7 +1820,7 @@ function Workshop({ slug }: { slug: string }) { : "text-muted-foreground hover:text-foreground" }`} > - +
@@ -1908,14 +1846,14 @@ function Workshop({ slug }: { slug: string }) { {srcDoc ? ( previewDevice === "mobile" ? (
- +
) : ( - + ) ) : (
- +

No preview yet

@@ -1930,14 +1868,11 @@ function Workshop({ slug }: { slug: string }) { {/* Console drawer — frame errors mirrored by the cc SDK. */}
- +
{consoleEvents.length > 0 && ( +
{showNewFile && (
@@ -2020,12 +1951,9 @@ function Workshop({ slug }: { slug: string }) { placeholder="src/Widget.tsx" className="flex-1 min-w-0 rounded-md border border-border bg-background px-1.5 py-1 font-mono text-[11px] text-foreground focus:outline-none focus:ring-1 focus:ring-primary" /> - +
)} {files.length === 0 ? ( @@ -2048,8 +1976,7 @@ function Workshop({ slug }: { slug: string }) {
{isMobile && ( - + )} {selectedPath ? ( <> - + {selectedPath} @@ -2104,45 +2028,40 @@ function Workshop({ slug }: { slug: string }) {
{buildStatus === "building" && ( - Building… + Building… )} {selectedPath && ( - + )}
{selectedPath === null ? (
- +

Select a file to edit, or use{" "} - to create one. + to create one. Uploaded assets (via the chat's attach button) land under inputs/.

) : editedContent === null ? (
- +
) : ( setEditedContent(v ?? "")} options={{ @@ -2157,7 +2076,7 @@ function Workshop({ slug }: { slug: string }) {
{buildError && (
- +
                       {buildError}
                     
@@ -2183,7 +2102,7 @@ function Workshop({ slug }: { slug: string }) { onClick={() => deleteFile(deletePath)} className="flex items-center gap-1.5 text-xs rounded-md bg-destructive px-3 py-1.5 font-medium text-destructive-foreground hover:opacity-90 tech-transition" > - Delete + Delete
@@ -2194,7 +2113,7 @@ function Workshop({ slug }: { slug: string }) { /* Tests — empty state. Authoring stays conversational (RFC §4.9) — no form/editor here, just a nudge toward chat. */
- +

No test scenarios yet

@@ -2211,19 +2130,14 @@ function Workshop({ slug }: { slug: string }) { {testScenarios.length} passing
- +
{testScenarios.map((scenario) => { @@ -2250,9 +2164,9 @@ function Workshop({ slug }: { slug: string }) { className="flex items-center gap-1.5 flex-1 min-w-0 text-left" > {expanded ? ( - + ) : ( - + )} {scenario.name} @@ -2273,19 +2187,14 @@ function Workshop({ slug }: { slug: string }) { ? "Fail" : "Not run"} - +
{expanded && (
@@ -2295,7 +2204,7 @@ function Workshop({ slug }: { slug: string }) {

) : result.passed ? (
- + All {result.steps.length} steps and{" "} {result.assertions.length} assertions passed.
@@ -2314,7 +2223,7 @@ function Workshop({ slug }: { slug: string }) { key={`step-${i}`} className="flex items-start gap-1.5" > - + {describeStep(s.step)} {s.error ? `: ${s.error}` : ""} @@ -2328,7 +2237,7 @@ function Workshop({ slug }: { slug: string }) { key={`assertion-${i}`} className="flex items-start gap-1.5" > - + {describeAssertion(a.assertion)} — got{" "} {JSON.stringify(a.actual)} @@ -2338,7 +2247,7 @@ function Workshop({ slug }: { slug: string }) { ))} {result.error && (
- + {result.error} @@ -2363,7 +2272,7 @@ function Workshop({ slug }: { slug: string }) { }`} >
- +
Build chat @@ -2383,7 +2292,7 @@ function Workshop({ slug }: { slug: string }) { key={scope} className="flex items-start gap-2.5 rounded-lg border border-border bg-secondary px-3 py-2.5" > - +

New capability requested:{" "} @@ -2396,13 +2305,9 @@ function Workshop({ slug }: { slug: string }) { until an admin grants it at publish.

- +
))}
@@ -2423,7 +2328,7 @@ function Workshop({ slug }: { slug: string }) { key={result.scenarioId} className="flex items-start gap-2.5 rounded-lg border border-border bg-secondary px-3 py-2.5" > - +

Test failing:{" "} @@ -2441,13 +2346,9 @@ function Workshop({ slug }: { slug: string }) { ✦ Fix with AI

- +
); })} @@ -2457,7 +2358,7 @@ function Workshop({ slug }: { slug: string }) {
{!app.workspace_path ? (
- +

Read-only

You can browse this app's preview and code, but only its @@ -2478,7 +2379,7 @@ function Workshop({ slug }: { slug: string }) { /> ) : (

- +
)}
@@ -2506,7 +2407,7 @@ function Workshop({ slug }: { slug: string }) { {pendingConfirm && (
- +

Preview wants to use{" "} @@ -2533,30 +2434,24 @@ function Workshop({ slug }: { slug: string }) { /> Always allow for this app - - + +

)} @@ -2576,7 +2471,7 @@ export default function WorkshopPage({ - +
} > diff --git a/workbench/control_plane/src/app/build/apps/[slug]/page.tsx b/workbench/control_plane/src/app/build/apps/[slug]/page.tsx index ab525b342..d22386027 100644 --- a/workbench/control_plane/src/app/build/apps/[slug]/page.tsx +++ b/workbench/control_plane/src/app/build/apps/[slug]/page.tsx @@ -8,24 +8,12 @@ * user/storage/ai calls with the VIEWER's session (docs/app-workshop §4.4). */ +import Button from "@/components/ui/Button"; +import AppIcon, { themedIcon } from "@/components/Icon"; +import type { ThemedIcon } from "@/components/Icon"; import { use, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; -import { useTheme } from "next-themes"; -import { - AlertTriangle, - Database, - GitFork, - Hammer, - HelpCircle, - Info, - Loader2, - Plug, - Sparkles, - User, - Wrench, - type LucideIcon, -} from "lucide-react"; import SandboxedHtml from "@/components/SandboxedHtml"; import { buildAppSrcDoc, @@ -34,7 +22,6 @@ import { type CcToolConfirmDecision, type CcToolConfirmRequest, } from "../lib/ccBridge"; -import { buildIconMap } from "@/lib/iconSvg"; import type { AppMeta, AppUsage, AppVersion } from "../lib/types"; /** A pending `cc.tools.call()` confirm, waiting on the viewer's decision. */ @@ -134,12 +121,12 @@ function describeScope(scope: string): string { } /** Icon per scope category — `HelpCircle` for anything unrecognized. */ -function scopeIcon(scope: string): LucideIcon { - if (scope === "identity:read") return User; - if (scope === "storage:app") return Database; - if (scope.startsWith("ai:")) return Sparkles; - if (scope.startsWith("tool:")) return Plug; - return HelpCircle; +function scopeIcon(scope: string): ThemedIcon { + if (scope === "identity:read") return themedIcon("User"); + if (scope === "storage:app") return themedIcon("Database"); + if (scope.startsWith("ai:")) return themedIcon("Sparkles"); + if (scope.startsWith("tool:")) return themedIcon("Plug"); + return themedIcon("HelpCircle"); } export default function AppRunPage({ @@ -151,8 +138,6 @@ export default function AppRunPage({ const router = useRouter(); const { data: session } = useSession(); const viewerEmail = session?.user?.email ?? "dev@fracktal.in"; - const { resolvedTheme } = useTheme(); - const theme: "light" | "dark" = resolvedTheme === "light" ? "light" : "dark"; const [app, setApp] = useState(null); const [bundle, setBundle] = useState(null); @@ -404,7 +389,7 @@ export default function AppRunPage({ // Same icon pre-resolution as the Workshop's preview — the published run // page goes through the exact same sandboxed frame. const runIcons = useMemo( - () => (srcDoc ? buildIconMap(extractCcIconNames(srcDoc)) : {}), + () => (srcDoc ? extractCcIconNames(srcDoc) : []), [srcDoc] ); @@ -413,7 +398,7 @@ export default function AppRunPage({ if (loading) { return (
- +

Loading app…

); @@ -423,12 +408,9 @@ export default function AppRunPage({ return (

{error ?? "App not found."}

- +
); } @@ -463,28 +445,19 @@ export default function AppRunPage({ runs as {viewerEmail} - + {canEdit && ( - + )}
{/* Info popover */} @@ -521,7 +494,7 @@ export default function AppRunPage({ Versions {versions === null ? ( - + ) : versions.length === 0 ? ( No published versions. @@ -546,12 +519,9 @@ export default function AppRunPage({ live ) : canEdit && confirmVersion !== v.version ? ( - + ) : null}
{canEdit && !isCurrent && confirmVersion === v.version && ( @@ -559,16 +529,12 @@ export default function AppRunPage({ Make v{v.version} live? - + + )}
)} @@ -676,20 +639,13 @@ export default function AppRunPage({ )}
- - + +
@@ -700,7 +656,7 @@ export default function AppRunPage({ {pendingConfirm && (
- +

{app.name} wants to use{" "} @@ -726,30 +682,24 @@ export default function AppRunPage({ /> Always allow for this app - - + +

)} diff --git a/workbench/control_plane/src/app/build/apps/page.tsx b/workbench/control_plane/src/app/build/apps/page.tsx index 80fad359f..36a813ccc 100644 --- a/workbench/control_plane/src/app/build/apps/page.tsx +++ b/workbench/control_plane/src/app/build/apps/page.tsx @@ -8,23 +8,11 @@ * the published app full-page, drafts (and editors) jump into the Workshop. */ +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useSession } from "next-auth/react"; -import { - Clock, - GitFork, - Hammer, - LayoutTemplate, - Loader2, - Pin, - PinOff, - Plus, - RefreshCw, - Sparkles, - X, - Zap, -} from "lucide-react"; import FilterPills from "@/components/FilterPills"; import type { AppMeta } from "./lib/types"; @@ -133,7 +121,7 @@ function AppCard({ : "text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground" }`} > - + )} {app.pinned ? ( - + ) : ( - + )}
@@ -185,7 +173,7 @@ function AppCard({ {isLive && ·} by {app.owner_email.split("@")[0]} · - + {formatRelative(app.updated_at)}
@@ -213,7 +201,7 @@ function AppCard({ title={`${app.month_calls} AI call${app.month_calls === 1 ? "" : "s"} this month`} className="flex items-center gap-1 text-[10.5px] text-muted-foreground" > - + {formatCost(app.month_cost_usd ?? 0)} )} @@ -235,7 +223,7 @@ function AppCard({ }} className="flex items-center gap-1 text-[11px] font-semibold text-accent hover:opacity-80 tech-transition" > - + Use )} @@ -470,20 +458,16 @@ export default function CustomAppsPage() { title="Refresh" className="p-2 rounded-lg border border-border text-muted-foreground hover:bg-secondary tech-transition" > - + - +
@@ -502,7 +486,7 @@ export default function CustomAppsPage() { {forkError && (
- + {forkError} +
Try: @@ -554,7 +534,7 @@ export default function CustomAppsPage() {
{createError && (
- {createError} + {createError}
)}
@@ -562,7 +542,7 @@ export default function CustomAppsPage() { {/* Loading / error / empty */} {loading && (
- +

Loading apps…

)} @@ -582,7 +562,7 @@ export default function CustomAppsPage() { {!loading && !error && visible.length === 0 && (
- +

{apps.length === 0 ? "No apps yet" : "Nothing matches this filter"} diff --git a/workbench/control_plane/src/app/centers/[slug]/page.tsx b/workbench/control_plane/src/app/centers/[slug]/page.tsx index a5158f2a7..0b82c7d14 100644 --- a/workbench/control_plane/src/app/centers/[slug]/page.tsx +++ b/workbench/control_plane/src/app/centers/[slug]/page.tsx @@ -14,7 +14,7 @@ import Link from "next/link"; import { useParams } from "next/navigation"; import { centerBySlug } from "@/lib/centers"; -import { resolveIcon } from "@/lib/icons"; +import ThemedIcon from "@/components/Icon"; export default function CenterPage() { const params = useParams<{ slug: string }>(); @@ -32,7 +32,6 @@ export default function CenterPage() { ); } - const CenterIcon = resolveIcon(center.icon); const live = center.apps.filter((a) => a.status === "live"); const planned = center.apps.filter((a) => a.status === "planned"); @@ -41,7 +40,7 @@ export default function CenterPage() { {/* Header */}

- +
@@ -62,7 +61,6 @@ export default function CenterPage() {
{live.map((app) => { - const Icon = resolveIcon(app.icon); return ( - +
{app.label}

{app.note}

@@ -92,7 +90,6 @@ export default function CenterPage() {
{planned.map((app) => { - const Icon = resolveIcon(app.icon); return (
- + Planned diff --git a/workbench/control_plane/src/app/chat/page.tsx b/workbench/control_plane/src/app/chat/page.tsx index 721b23d36..4ba30c4e5 100644 --- a/workbench/control_plane/src/app/chat/page.tsx +++ b/workbench/control_plane/src/app/chat/page.tsx @@ -1,9 +1,10 @@ "use client"; +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useState, useEffect, useCallback, useMemo, useRef, Suspense } from "react"; import { useSearchParams } from "next/navigation"; import { useSession } from "next-auth/react"; -import { Bot, MessagesSquare, Search, Trash2, Users } from "lucide-react"; import BreathingCharacter, { characterForAgent } from "@/components/BreathingCharacter"; import { getSessions, @@ -91,7 +92,7 @@ function AgentPickerCard({ } + fallback={} /> } /> @@ -191,12 +192,9 @@ function AgentPickerModal({
New session
Choose an agent to chat with
- +
@@ -398,7 +396,7 @@ function SessionList({ {/* Search — client-side filter over titles, previews, and agent names. */}
- + setQuery(e.target.value)} @@ -434,7 +432,7 @@ function SessionList({ } + fallback={} /> @@ -491,7 +489,7 @@ function SessionList({ : `Shared · ${s.participantCount} in the room` } > - + {s.participantCount} )} @@ -522,7 +520,7 @@ function SessionList({ title="Delete conversation" aria-label="Delete conversation" > - +
); @@ -874,15 +872,11 @@ function ChatPageInner() { <>
Conversations
- +
Files
- +
{/* Upload drop zone at top of files drawer */} @@ -989,7 +979,7 @@ function ChatPageInner() { className="flex w-full flex-1 cursor-pointer flex-col items-center py-2.5 text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors" title="Open conversations" > - + {sessions.length > 0 && ( {sessions.length} @@ -1159,12 +1149,9 @@ function ChatPageInner() { ) : (
Choose an agent to start chatting
- +
)}
diff --git a/workbench/control_plane/src/app/crm/components/ConvertModal.tsx b/workbench/control_plane/src/app/crm/components/ConvertModal.tsx index 8480b2c25..516a8f4ea 100644 --- a/workbench/control_plane/src/app/crm/components/ConvertModal.tsx +++ b/workbench/control_plane/src/app/crm/components/ConvertModal.tsx @@ -13,7 +13,7 @@ * modal reads as a confirmation rather than as a second opinion. */ -import { X } from "lucide-react"; +import Icon from "@/components/Icon"; import { useState } from "react"; import { canConvert, @@ -83,7 +83,7 @@ export default function ConvertModal({ onClick={onClose} className="rounded-lg border border-border p-2 text-muted-foreground hover:bg-secondary tech-transition" > - + diff --git a/workbench/control_plane/src/app/crm/components/FieldsPanel.tsx b/workbench/control_plane/src/app/crm/components/FieldsPanel.tsx index f9e8d197e..731806c94 100644 --- a/workbench/control_plane/src/app/crm/components/FieldsPanel.tsx +++ b/workbench/control_plane/src/app/crm/components/FieldsPanel.tsx @@ -10,7 +10,8 @@ * opened and saved is a form nobody opens. */ -import { Check, Star, UserMinus, X } from "lucide-react"; +import Icon from "@/components/Icon"; +import Button from "@/components/ui/Button"; import { useState } from "react"; import { money, shortDate } from "../lib/format"; import StatusPill from "./StatusPill"; @@ -122,12 +123,9 @@ export default function FieldsPanel({ converted ) : ( - + ))}
)} @@ -169,7 +167,7 @@ export default function FieldsPanel({ : "text-muted-foreground hover:text-foreground" }`} > - @@ -179,7 +177,7 @@ export default function FieldsPanel({ title="Remove from this deal (the contact record is kept)" className="p-1 rounded text-muted-foreground hover:text-destructive tech-transition" > - + ))} @@ -266,14 +264,14 @@ function EditableField({ className="p-1 rounded text-muted-foreground hover:text-success tech-transition" aria-label="Save" > - +
) : ( diff --git a/workbench/control_plane/src/app/crm/components/KanbanBoard.tsx b/workbench/control_plane/src/app/crm/components/KanbanBoard.tsx index 7982d7c53..918fc36a3 100644 --- a/workbench/control_plane/src/app/crm/components/KanbanBoard.tsx +++ b/workbench/control_plane/src/app/crm/components/KanbanBoard.tsx @@ -12,7 +12,7 @@ * in ../lib/board.ts — this file is the pixels and the HTML5 drag events. */ -import { Building2, IndianRupee, Plus, User } from "lucide-react"; +import Icon from "@/components/Icon"; import { useState } from "react"; import { needsLostReason, planMove, type BoardLane, type DealMove } from "../lib/board"; import { compactMoney, money, shortEmail, stageAgeLabel } from "../lib/format"; @@ -90,7 +90,7 @@ export default function KanbanBoard({
- + {/* The lane total covers the whole lane; the cards below are one page of it. A header that counted only what it returned would lie about the busy lane somebody is @@ -133,7 +133,7 @@ export default function KanbanBoard({ onClick={onCreate} className="flex h-9 w-[180px] shrink-0 items-center justify-center gap-1.5 rounded-xl border border-dashed border-border text-xs text-muted-foreground hover:border-primary/40 hover:text-foreground tech-transition" > - + New deal
@@ -167,7 +167,7 @@ function DealCard({ browser never joins the org list, which is paged at 100. */} {deal.organization_name && (

- + {deal.organization_name}

)} @@ -183,7 +183,7 @@ function DealCard({
- + {shortEmail(deal.owner_email)} {warnLost && ( diff --git a/workbench/control_plane/src/app/crm/components/LostReasonModal.tsx b/workbench/control_plane/src/app/crm/components/LostReasonModal.tsx index 389534a6c..a5fab4728 100644 --- a/workbench/control_plane/src/app/crm/components/LostReasonModal.tsx +++ b/workbench/control_plane/src/app/crm/components/LostReasonModal.tsx @@ -10,7 +10,7 @@ * travels WITH the move so a single PATCH either lands or does not. */ -import { X } from "lucide-react"; +import Icon from "@/components/Icon"; import { useState } from "react"; import type { LostReason } from "../lib/types"; @@ -51,7 +51,7 @@ export default function LostReasonModal({ onClick={onCancel} className="rounded-lg border border-border p-2 text-muted-foreground hover:bg-secondary tech-transition" > - + diff --git a/workbench/control_plane/src/app/crm/components/QuickCreateModal.tsx b/workbench/control_plane/src/app/crm/components/QuickCreateModal.tsx index ae8815683..171193352 100644 --- a/workbench/control_plane/src/app/crm/components/QuickCreateModal.tsx +++ b/workbench/control_plane/src/app/crm/components/QuickCreateModal.tsx @@ -12,7 +12,7 @@ * unassigned. Offering a picker here would make the common case a decision. */ -import { X } from "lucide-react"; +import Icon from "@/components/Icon"; import { useState } from "react"; import type { EntitySlug } from "../lib/types"; @@ -116,7 +116,7 @@ export default function QuickCreateModal({ onClick={onClose} className="rounded-lg border border-border p-2 text-muted-foreground hover:bg-secondary tech-transition" > - + diff --git a/workbench/control_plane/src/app/crm/components/RecordList.tsx b/workbench/control_plane/src/app/crm/components/RecordList.tsx index ebeec31af..f529a0935 100644 --- a/workbench/control_plane/src/app/crm/components/RecordList.tsx +++ b/workbench/control_plane/src/app/crm/components/RecordList.tsx @@ -10,7 +10,7 @@ * request is built once in ../lib/filters.ts. */ -import { ArrowDown, ArrowUp } from "lucide-react"; +import Icon from "@/components/Icon"; import { SORTS } from "../lib/filters"; import { money, shortDate, shortEmail } from "../lib/format"; import type { EntitySlug } from "../lib/types"; @@ -177,9 +177,9 @@ export default function RecordList({ {column.label} {sort === column.sort && (direction === "asc" ? ( - + ) : ( - + ))} ) : ( diff --git a/workbench/control_plane/src/app/crm/components/RecordSheet.tsx b/workbench/control_plane/src/app/crm/components/RecordSheet.tsx index fe39b82ff..7fcff53ae 100644 --- a/workbench/control_plane/src/app/crm/components/RecordSheet.tsx +++ b/workbench/control_plane/src/app/crm/components/RecordSheet.tsx @@ -13,7 +13,7 @@ * entirely in the field list. */ -import { X } from "lucide-react"; +import Icon from "@/components/Icon"; import { useViewMode } from "@/components/ViewModeProvider"; import FieldsPanel from "./FieldsPanel"; import Timeline from "./Timeline"; @@ -103,7 +103,7 @@ export default function RecordSheet({ className="rounded-lg border border-border p-2 text-muted-foreground hover:bg-secondary tech-transition" aria-label="Close record" > - + diff --git a/workbench/control_plane/src/app/crm/components/StatusPill.tsx b/workbench/control_plane/src/app/crm/components/StatusPill.tsx index 17b1e4dc5..a5f118541 100644 --- a/workbench/control_plane/src/app/crm/components/StatusPill.tsx +++ b/workbench/control_plane/src/app/crm/components/StatusPill.tsx @@ -8,7 +8,7 @@ * same PATCH the kanban drag does (lib/board.ts::moveRequest). */ -import { ChevronDown } from "lucide-react"; +import Icon from "@/components/Icon"; import { useState } from "react"; import { statusTone } from "../lib/board"; import type { Status } from "../lib/types"; @@ -47,7 +47,7 @@ export default function StatusPill({ > {label} - + {open && ( <> diff --git a/workbench/control_plane/src/app/crm/components/Timeline.tsx b/workbench/control_plane/src/app/crm/components/Timeline.tsx index 553bb555f..8fdbad85d 100644 --- a/workbench/control_plane/src/app/crm/components/Timeline.tsx +++ b/workbench/control_plane/src/app/crm/components/Timeline.tsx @@ -15,26 +15,19 @@ * editable history is not a record of anything. */ -import { - ArrowRight, - CheckCircle2, - Circle, - ListTodo, - MessageSquare, - Phone, - Users, -} from "lucide-react"; +import Icon from "@/components/Icon"; +import Button from "@/components/ui/Button"; import { useState } from "react"; import { dateTime, dwellLabel } from "../lib/format"; import type { TimelineEntry } from "../lib/types"; type Composable = "note" | "call" | "meeting" | "task"; -const COMPOSERS: { id: Composable; label: string; icon: typeof MessageSquare }[] = [ - { id: "note", label: "Note", icon: MessageSquare }, - { id: "task", label: "Task", icon: ListTodo }, - { id: "call", label: "Call", icon: Phone }, - { id: "meeting", label: "Meeting", icon: Users }, +const COMPOSERS: { id: Composable; label: string; icon: string }[] = [ + { id: "note", label: "Note", icon: "MessageSquare" }, + { id: "task", label: "Task", icon: "ListTodo" }, + { id: "call", label: "Call", icon: "Phone" }, + { id: "meeting", label: "Meeting", icon: "Users" }, ]; export default function Timeline({ @@ -76,7 +69,7 @@ export default function Timeline({
- {COMPOSERS.map(({ id, label, icon: Icon }) => ( + {COMPOSERS.map(({ id, label, icon }) => ( ))} @@ -109,13 +102,16 @@ export default function Timeline({ className="rounded-lg border border-border bg-background px-2 py-1 text-xs text-foreground" /> )} - +
@@ -160,7 +156,7 @@ function StatusEntry({ entry }: { entry: TimelineEntry }) { const dwell = dwellLabel(change.dwell_seconds); return (
- +

{change.from_status ?? "—"} {" "} @@ -183,14 +179,14 @@ function StatusEntry({ entry }: { entry: TimelineEntry }) { ); } -const ACTIVITY_ICONS = { - note: MessageSquare, - call: Phone, - meeting: Users, - task: ListTodo, - status_change: ArrowRight, - system: Circle, -} as const; +const ACTIVITY_ICONS: Record = { + note: "MessageSquare", + call: "Phone", + meeting: "Users", + task: "ListTodo", + status_change: "ArrowRight", + system: "Circle", +}; function ActivityEntry({ entry, @@ -200,7 +196,7 @@ function ActivityEntry({ onToggleTask: (activityId: string, completed: boolean) => void; }) { const activity = entry.activity!; - const Icon = ACTIVITY_ICONS[activity.type] ?? Circle; + const iconName = ACTIVITY_ICONS[activity.type] ?? "Circle"; const isTask = activity.type === "task"; const done = Boolean(activity.completed_at); @@ -213,13 +209,13 @@ function ActivityEntry({ aria-label={done ? "Reopen task" : "Complete task"} > {done ? ( - + ) : ( - + )} ) : ( - + )}

{activity.subject && ( diff --git a/workbench/control_plane/src/app/crm/page.tsx b/workbench/control_plane/src/app/crm/page.tsx index a38bab127..443591f49 100644 --- a/workbench/control_plane/src/app/crm/page.tsx +++ b/workbench/control_plane/src/app/crm/page.tsx @@ -15,7 +15,7 @@ * pure and unit-tested. This file is composition and effects. */ -import { Kanban, Plus, RefreshCw, X } from "lucide-react"; +import Icon from "@/components/Icon"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useState } from "react"; import FilterPills from "@/components/FilterPills"; @@ -43,7 +43,7 @@ import { } from "./lib/urlState"; const TABS = [ - { id: "board", label: "Pipeline", icon: Kanban }, + { id: "board", label: "Pipeline", icon: "Kanban" }, { id: "deals", label: "Deals" }, { id: "leads", label: "Leads" }, { id: "contacts", label: "Contacts" }, @@ -167,7 +167,7 @@ function CrmPageInner() { className="rounded-lg border border-border p-2 text-muted-foreground hover:bg-secondary tech-transition" aria-label="Refresh" > - +
@@ -206,7 +206,7 @@ function CrmPageInner() { className="text-destructive/70 hover:text-destructive" aria-label="Dismiss" > - +
)} diff --git a/workbench/control_plane/src/app/email/components/AccountSidebar.tsx b/workbench/control_plane/src/app/email/components/AccountSidebar.tsx index ee13b2a75..a4b50ef6f 100644 --- a/workbench/control_plane/src/app/email/components/AccountSidebar.tsx +++ b/workbench/control_plane/src/app/email/components/AccountSidebar.tsx @@ -1,12 +1,7 @@ "use client"; +import AppIcon, { themedIcon } from "@/components/Icon"; import { useState } from "react"; -import { - Inbox, Send, FileText, Trash2, Star, Archive, Tag, - Plus, ChevronDown, ChevronRight, Check, - ShieldAlert, Folder, Mails, MailMinus, Sparkles, - BarChart3, Zap, LayoutDashboard, MessageSquare, Clock, -} from "lucide-react"; import { EmailAccount, EmailFolder, AutomationFeature } from "../lib/types"; interface AccountSidebarProps { @@ -36,11 +31,11 @@ const AUTOMATION_ITEMS: { label: string; icon: React.ElementType; }[] = [ - { key: "chat", label: "Chat", icon: MessageSquare }, - { key: "digest", label: "Dashboard", icon: LayoutDashboard }, - { key: "unsubscribe", label: "Email Cleaner", icon: MailMinus }, - { key: "ai-settings", label: "AI Settings", icon: Sparkles }, - { key: "analytics", label: "Analytics", icon: BarChart3 }, + { key: "chat", label: "Chat", icon: themedIcon("MessageSquare") }, + { key: "digest", label: "Dashboard", icon: themedIcon("LayoutDashboard") }, + { key: "unsubscribe", label: "Email Cleaner", icon: themedIcon("MailMinus") }, + { key: "ai-settings", label: "AI Settings", icon: themedIcon("Sparkles") }, + { key: "analytics", label: "Analytics", icon: themedIcon("BarChart3") }, ]; export function AccountSidebar({ @@ -73,7 +68,7 @@ export function AccountSidebar({ title="Add email account" onClick={onAddAccount} > - +
@@ -83,7 +78,7 @@ export function AccountSidebar({ onClick={() => setAccountsExpanded((v) => !v)} className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-muted-foreground hover:text-sidebar-foreground transition-colors" > - {accountsExpanded ? : } + {accountsExpanded ? : } Email Accounts @@ -116,7 +111,7 @@ export function AccountSidebar({
{account.label} {account.isDefault && ( - - + )} {selectedAccountId === account.id && ( - + )} {account.unreadCount > 0 && selectedAccountId !== account.id && ( @@ -163,7 +158,7 @@ export function AccountSidebar({ {showAutomation && (
- + Email Automation
@@ -229,18 +224,18 @@ export function AccountSidebar({ // Helper to map folder key to Lucide icon component. function getFolderIcon(key: string, type?: "system" | "user"): React.ElementType { const map: Record = { - all: Mails, - inbox: Inbox, - starred: Star, - snoozed: Clock, - sent: Send, - drafts: FileText, - archive: Archive, - junk: ShieldAlert, - labels: Tag, - trash: Trash2, + all: themedIcon("Mails"), + inbox: themedIcon("Inbox"), + starred: themedIcon("Star"), + snoozed: themedIcon("Clock"), + sent: themedIcon("Send"), + drafts: themedIcon("FileText"), + archive: themedIcon("Archive"), + junk: themedIcon("ShieldAlert"), + labels: themedIcon("Tag"), + trash: themedIcon("Trash2"), }; if (map[key]) return map[key]; // User-created provider folders/labels get a generic folder icon. - return type === "user" ? Folder : Inbox; + return type === "user" ? themedIcon("Folder") : themedIcon("Inbox"); } diff --git a/workbench/control_plane/src/app/email/components/ArtifactAttachPicker.tsx b/workbench/control_plane/src/app/email/components/ArtifactAttachPicker.tsx index 87a99fb77..79d970225 100644 --- a/workbench/control_plane/src/app/email/components/ArtifactAttachPicker.tsx +++ b/workbench/control_plane/src/app/email/components/ArtifactAttachPicker.tsx @@ -8,8 +8,9 @@ * send time — no base64 round-trip in the browser). */ +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useState, useEffect, useRef } from "react"; -import { Sparkles, Loader2, Paperclip } from "lucide-react"; import { listEmailArtifacts, type EmailArtifact } from "../lib/api"; export function ArtifactAttachPicker({ @@ -51,22 +52,16 @@ export function ArtifactAttachPicker({ return (
- + AI files + {open && (
{loading ? (
- Loading… + Loading…
) : available.length === 0 ? (
@@ -83,7 +78,7 @@ export function ArtifactAttachPicker({ }} className="w-full text-left px-3 py-1.5 hover:bg-secondary transition-colors flex items-center gap-2" > - + {a.name} diff --git a/workbench/control_plane/src/app/email/components/AttachmentList.tsx b/workbench/control_plane/src/app/email/components/AttachmentList.tsx index f1f70546d..d9e98bf06 100644 --- a/workbench/control_plane/src/app/email/components/AttachmentList.tsx +++ b/workbench/control_plane/src/app/email/components/AttachmentList.tsx @@ -1,7 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useState } from "react"; -import { ChevronDown, Download, Eye, FileText } from "lucide-react"; import { Attachment } from "../lib/types"; import { getAttachmentDownloadUrl } from "../lib/api"; import { formatBytes } from "../lib/utils"; @@ -89,12 +90,12 @@ export function AttachmentList({ className="w-14 h-14 object-cover bg-background block" /> - + ) : ( - + )} @@ -125,23 +126,20 @@ export function AttachmentList({ title={`Download ${att.filename}`} className="px-2 self-stretch flex items-center text-muted-foreground hover:text-foreground hover:bg-secondary/60 transition-colors flex-shrink-0" > - +
); })}
{overflow > 0 && ( - + )} {/* Pop-up viewer — images, PDFs and documents all render here. Read-only: diff --git a/workbench/control_plane/src/app/email/components/CommandPalette.tsx b/workbench/control_plane/src/app/email/components/CommandPalette.tsx index da4ecbea9..48ebbbb2b 100644 --- a/workbench/control_plane/src/app/email/components/CommandPalette.tsx +++ b/workbench/control_plane/src/app/email/components/CommandPalette.tsx @@ -1,7 +1,7 @@ "use client"; +import Icon from "@/components/Icon"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Search } from "lucide-react"; export interface Command { id: string; @@ -68,7 +68,7 @@ export function CommandPalette({
- + New Message - +
{/* Fields — the scrolling region when the window hits its max height */} @@ -330,14 +328,14 @@ export function ComposePanel({ key={`f-${i}`} className="inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-md border border-border bg-secondary text-muted-foreground" > - + {a.filename} ))} @@ -347,14 +345,14 @@ export function ComposePanel({ className="inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-md border border-primary/40 bg-primary/5 text-primary" title={a.path} > - + {a.name || a.path} ))} @@ -402,7 +400,7 @@ export function ComposePanel({ className="px-2 py-1.5 text-xs rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors cursor-pointer flex items-center" title="Attach files" > - + setArtifacts((prev) => prev.some((a) => a.path === ref.path) ? prev : [...prev, ref])} /> - - + +
diff --git a/workbench/control_plane/src/app/email/components/ComposerAI.tsx b/workbench/control_plane/src/app/email/components/ComposerAI.tsx index f26f19804..41e229d1c 100644 --- a/workbench/control_plane/src/app/email/components/ComposerAI.tsx +++ b/workbench/control_plane/src/app/email/components/ComposerAI.tsx @@ -1,7 +1,7 @@ "use client"; +import Icon from "@/components/Icon"; import { useState } from "react"; -import { Sparkles, MoreHorizontal } from "lucide-react"; /** * The quoted trailing email shown in a COMPOSE box — collapsed behind an @@ -31,7 +31,7 @@ export function ComposerQuote({ : "border-border bg-secondary text-muted-foreground hover:bg-secondary/70 hover:text-foreground" }`} > - + {open && (
@@ -66,7 +66,7 @@ export function AiButton({ : "text-muted-foreground hover:text-foreground hover:bg-secondary" }`} > - + ); } diff --git a/workbench/control_plane/src/app/email/components/ContactCard.tsx b/workbench/control_plane/src/app/email/components/ContactCard.tsx index a7b4b8c70..60c6c2c8a 100644 --- a/workbench/control_plane/src/app/email/components/ContactCard.tsx +++ b/workbench/control_plane/src/app/email/components/ContactCard.tsx @@ -19,12 +19,10 @@ * above / to the left near an edge, and becomes a bottom sheet on mobile). */ +import Button from "@/components/ui/Button"; +import AppIcon, { themedIcon, type ThemedIcon } from "@/components/Icon"; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { - AtSign, Building2, Copy, Check, ExternalLink, Loader2, Mail, Paperclip, - Phone, Search, X, -} from "lucide-react"; import { getContactCard } from "../lib/api"; import { useEmailStore } from "../lib/emailStore"; import { @@ -183,7 +181,7 @@ function CopyButton({ value, label }: { value: string; label: string }) { : "text-muted-foreground opacity-45 group-hover:opacity-80 hover:!opacity-100 hover:text-foreground hover:bg-secondary" }`} > - {done ? : } + {done ? : } ); } @@ -196,7 +194,7 @@ function DetailRow({ href, display, }: { - icon: typeof Phone; + icon: ThemedIcon; value: string; label: string; href?: string; @@ -437,26 +435,18 @@ function ContactPopover({ aria-label="Close contact card" className="p-1 rounded-lg text-muted-foreground hover:bg-secondary tech-transition shrink-0" > - +
{/* Actions */}
- - + +
{error ? ( @@ -465,7 +455,7 @@ function ContactPopover({
) : !card ? (
- Loading contact… + Loading contact…
) : ( <> @@ -475,7 +465,7 @@ function ContactPopover({ {details?.phones.map((phone) => ( )} {card.domain && !details?.organization && ( - + )} {details?.links.map((link) => ( {m.hasAttachments && ( - + )} {m.receivedAt && ( diff --git a/workbench/control_plane/src/app/email/components/ConversationView.tsx b/workbench/control_plane/src/app/email/components/ConversationView.tsx index eff98f6ad..e63f5047b 100644 --- a/workbench/control_plane/src/app/email/components/ConversationView.tsx +++ b/workbench/control_plane/src/app/email/components/ConversationView.tsx @@ -1,10 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import AppIcon, { themedIcon } from "@/components/Icon"; import { useEffect, useRef, useState } from "react"; -import { - ChevronDown, Paperclip, PenLine, Send, Loader2, Trash2, - Reply, ReplyAll, Forward, -} from "lucide-react"; import { Email } from "../lib/types"; import { fullDateLabel, initials, buildOptimisticSent } from "../lib/utils"; import { getEmail, fetchFullBody, detectReplyCommitment } from "../lib/api"; @@ -214,12 +212,12 @@ export function ConversationView({ )}
{m.hasAttachments && ( - + )} {fullDateLabel(m.receivedAt)} - onReply(view, "reply")} /> onReply(view, "reply-all")} /> onReply(view, "forward")} /> @@ -303,18 +301,12 @@ function CardAction({ onClick: () => void; }) { return ( - + ); } @@ -593,7 +585,7 @@ export function DraftCard({ return (
- + Draft {hasReplyTarget && (
@@ -605,7 +597,7 @@ export function DraftCard({ !replyAll ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground" }`} > - Reply + Reply
)} @@ -634,13 +626,9 @@ export function DraftCard({ className={`${INPUT} w-full`} /> {!showCc && ( - + )}
{showCc && ( @@ -697,23 +685,19 @@ export function DraftCard({
)}
- + setAiOpen((v) => !v)} /> diff --git a/workbench/control_plane/src/app/email/components/DraftAssistant.tsx b/workbench/control_plane/src/app/email/components/DraftAssistant.tsx index ebe7413e7..6eb79901b 100644 --- a/workbench/control_plane/src/app/email/components/DraftAssistant.tsx +++ b/workbench/control_plane/src/app/email/components/DraftAssistant.tsx @@ -1,17 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useEffect, useRef, useState } from "react"; -import { - Sparkles, - Loader2, - X, - CornerDownLeft, - Check, - ChevronDown, - ChevronUp, - AlertTriangle, - Undo2, -} from "lucide-react"; import type { DraftStep, DraftRevision } from "../lib/useDraftSession"; /** @@ -27,7 +18,7 @@ import type { DraftStep, DraftRevision } from "../lib/useDraftSession"; function StepIcon({ state }: { state: DraftStep["state"] }) { if (state === "running") { return ( - + ); } @@ -241,15 +232,9 @@ export function DraftAssistant({ )} {steps.length > 0 && ( - + )}
@@ -278,7 +263,7 @@ export function DraftAssistant({ )}
- + {hasDraft && revisions.length > 1 && !busy && ( - + }} title="Go back to the previous version" className="rounded flex-shrink-0"> + + )} - +
{/* Keeps the collapsed summary reachable for screen readers. */} diff --git a/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx b/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx index 2199d17a2..43cc58b47 100644 --- a/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx +++ b/workbench/control_plane/src/app/email/components/EmailAssistantChat.tsx @@ -18,9 +18,9 @@ * 3. Bridge the Assistant "Fix" flow (pendingChatPrompt → composer). */ +import Icon from "@/components/Icon"; import { useState, useEffect, useCallback, useMemo } from "react"; import { useSession } from "next-auth/react"; -import { Sparkles, Plus, MessagesSquare, Trash2, X, ArrowLeft } from "lucide-react"; import AgentChat from "@/components/AgentChat"; import { getSessions, createSession, upsertSession, deleteSession, @@ -252,11 +252,11 @@ export function EmailAssistantChat({ aria-label="Back to inbox" className="p-1 -ml-1 rounded text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors" > - + )}
- +
AI Assistant @@ -272,14 +272,14 @@ export function EmailAssistantChat({ : "text-muted-foreground hover:text-sidebar-foreground hover:bg-sidebar-accent" }`} > - +
@@ -295,7 +295,7 @@ export function EmailAssistantChat({ onClick={() => setShowSessions(false)} className="text-muted-foreground hover:text-foreground" > - +
{emailSessions.length === 0 ? ( @@ -337,7 +337,7 @@ export function EmailAssistantChat({ title="Delete conversation" className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive flex-shrink-0" > - +
)) diff --git a/workbench/control_plane/src/app/email/components/EmailDetail.tsx b/workbench/control_plane/src/app/email/components/EmailDetail.tsx index 1ad4ae0bd..f256d4501 100644 --- a/workbench/control_plane/src/app/email/components/EmailDetail.tsx +++ b/workbench/control_plane/src/app/email/components/EmailDetail.tsx @@ -1,12 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import AppIcon, { themedIcon } from "@/components/Icon"; import { useState, useEffect, useRef } from "react"; -import { - Star, Reply, Forward, Trash2, Archive, MoreHorizontal, - Paperclip, ReplyAll, Flag, FolderInput, - MailOpen, Tag, Printer, ExternalLink, X, AlertTriangle, Loader2, Send, - ListChecks, -} from "lucide-react"; import { Email } from "../lib/types"; import { fullDateLabel, initials, buildOptimisticSent, bodyMatchKey } from "../lib/utils"; import { useEmailStore, isRealFolder } from "../lib/emailStore"; @@ -686,26 +682,26 @@ export function EmailDetail({ email }: EmailDetailProps) { {/* Left group */}
startReply("reply-all")} active={replyMode === "reply-all"} /> startReply("reply")} active={replyMode === "reply"} /> startReply("forward")} active={replyMode === "forward"} /> { if (email) captureEmailToTasks(email.id); @@ -714,15 +710,15 @@ export function EmailDetail({ email }: EmailDetailProps) { - { + { if (email) updateEmail(email.id, { folder: "archive" }); }} /> - { + { if (email) deleteEmail(email.id); }} />
setShowMoveMenu((v) => !v)} active={showMoveMenu} @@ -759,7 +755,7 @@ export function EmailDetail({ email }: EmailDetailProps) { { if (email) { @@ -770,7 +766,7 @@ export function EmailDetail({ email }: EmailDetailProps) { active={flagged} /> { if (email) { @@ -781,7 +777,7 @@ export function EmailDetail({ email }: EmailDetailProps) { active={starred} /> { if (email) { @@ -793,7 +789,7 @@ export function EmailDetail({ email }: EmailDetailProps) { />
setShowLabelMenu((v) => !v)} active={showLabelMenu} @@ -813,13 +809,13 @@ export function EmailDetail({ email }: EmailDetailProps) { - window.print()} /> + window.print()} />
{/* More menu */}
setShowMoreMenu((v) => !v)} active={showMoreMenu} @@ -881,12 +877,12 @@ export function EmailDetail({ email }: EmailDetailProps) {
{view.importance === "high" && ( - Important + Important )} {flagged && ( - Flagged + Flagged )} {!read && ( @@ -971,7 +967,7 @@ export function EmailDetail({ email }: EmailDetailProps) { title="Add to Tasks — the assistant reads the thread and files a routed task (follow-up / delegated / next action) with a due date if implied." className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:border-primary/40 hover:text-primary transition-colors" > - + Add to Tasks
@@ -979,7 +975,7 @@ export function EmailDetail({ email }: EmailDetailProps) { {/* Body */} {loadingDetail ? (
- Loading message… + Loading message…
) : fullBodyText ? (
@@ -1027,7 +1023,7 @@ export function EmailDetail({ email }: EmailDetailProps) { disabled={loadingFullBody} className="flex items-center gap-1.5 text-xs text-primary hover:opacity-80 transition-opacity disabled:opacity-40" > - + {loadingFullBody ? "Loading…" : "Load full message from provider"}

@@ -1070,7 +1066,7 @@ export function EmailDetail({ email }: EmailDetailProps) { }`} title="Reply to all" > - +

)} @@ -1088,7 +1084,7 @@ export function EmailDetail({ email }: EmailDetailProps) { className="text-muted-foreground hover:text-foreground transition-colors" onClick={() => setReplyMode(null)} > - +
{/* Recipients */} @@ -1104,13 +1100,9 @@ export function EmailDetail({ email }: EmailDetailProps) { /> {/* Reveal Cc/Bcc on a sender-only reply where they're hidden. */} {!showReplyCc && ( - + )}
{showReplyCc && ( @@ -1160,7 +1152,7 @@ export function EmailDetail({ email }: EmailDetailProps) { key={`f-${i}`} className="inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-md border border-border bg-secondary text-muted-foreground" > - + {a.filename} @@ -1171,7 +1163,7 @@ export function EmailDetail({ email }: EmailDetailProps) { className="hover:text-foreground" title="Remove attachment" > - + ))} @@ -1181,7 +1173,7 @@ export function EmailDetail({ email }: EmailDetailProps) { className="inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-md border border-primary/40 bg-primary/5 text-primary" title={a.path} > - + {a.name || a.path} ))} @@ -1238,7 +1230,7 @@ export function EmailDetail({ email }: EmailDetailProps) { className="px-2 py-1 text-xs rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors cursor-pointer flex items-center" title="Attach files" > - + a.path === ref.path) ? prev : [...prev, ref]); }} /> - - + - + +
diff --git a/workbench/control_plane/src/app/email/components/EmailList.tsx b/workbench/control_plane/src/app/email/components/EmailList.tsx index fabfb3eef..bcbf07fab 100644 --- a/workbench/control_plane/src/app/email/components/EmailList.tsx +++ b/workbench/control_plane/src/app/email/components/EmailList.tsx @@ -1,13 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import AppIcon, { themedIcon } from "@/components/Icon"; import { useState, useRef, useEffect, useCallback } from "react"; -import { - Pencil, Trash2, Archive, Flag, FolderInput, - Reply, ReplyAll, Forward, MailOpen, Mail, Tag, - Paperclip, Star, AlertTriangle, ChevronRight, Loader2, Check, X, - MessagesSquare, RefreshCw, Minus, Plus, - ListChecks, Clock, AlarmClockOff, MessageCircle, -} from "lucide-react"; import { Email } from "../lib/types"; import { timeLabel } from "../lib/utils"; import { useEmailStore, isRealFolder } from "../lib/emailStore"; @@ -35,15 +30,15 @@ interface EmailListProps { // respond → dispose → organize. "move"/"label" open the context menu (folder & // label pickers); everything else routes through onToolbarAction. const TOOLBAR_ACTIONS = [ - { icon: Reply, label: "Reply", key: "reply" }, - { icon: ReplyAll, label: "Reply All", key: "reply-all" }, - { icon: Forward, label: "Forward", key: "forward" }, - { icon: Archive, label: "Archive", key: "archive" }, - { icon: Trash2, label: "Delete", key: "delete" }, - { icon: FolderInput, label: "Move", key: "move" }, - { icon: MailOpen, label: "Mark as Read", key: "mark-read" }, - { icon: Flag, label: "Flag", key: "flag" }, - { icon: Tag, label: "Label", key: "label" }, + { icon: themedIcon("Reply"), label: "Reply", key: "reply" }, + { icon: themedIcon("ReplyAll"), label: "Reply All", key: "reply-all" }, + { icon: themedIcon("Forward"), label: "Forward", key: "forward" }, + { icon: themedIcon("Archive"), label: "Archive", key: "archive" }, + { icon: themedIcon("Trash2"), label: "Delete", key: "delete" }, + { icon: themedIcon("FolderInput"), label: "Move", key: "move" }, + { icon: themedIcon("MailOpen"), label: "Mark as Read", key: "mark-read" }, + { icon: themedIcon("Flag"), label: "Flag", key: "flag" }, + { icon: themedIcon("Tag"), label: "Label", key: "label" }, ]; // Render a search highlight (ts_headline output) safely: the server wraps the @@ -289,30 +284,22 @@ export function EmailList({ {selected.size} selected
- bulkUpdate({ isRead: true })} /> - bulkUpdate({ isRead: false })} /> - bulkUpdate({ isFlagged: true })} /> - bulkUpdate({ folder: "archive" })} /> - - + bulkUpdate({ isRead: true })} /> + bulkUpdate({ isRead: false })} /> + bulkUpdate({ isFlagged: true })} /> + bulkUpdate({ folder: "archive" })} /> + +
) : (
{/* Compose — always available, even with nothing selected */} - + {selectedEmail ? ( <> @@ -352,17 +339,13 @@ export function EmailList({ {/* Active label filter */} {selectedLabel && (
- + Filtered by label {selectedLabel} - +
)} @@ -397,7 +380,7 @@ export function EmailList({ className="flex items-center justify-center text-muted-foreground overflow-hidden transition-[height]" style={{ height: syncing ? 36 : pullY }} > - ) : emails.length === 0 ? (
- +

No emails to show

) : ( @@ -474,21 +457,21 @@ export function EmailList({ className="inline-flex items-center gap-0.5 text-[9px] px-1 py-0.5 rounded-full bg-secondary text-muted-foreground" title={`${email.threadCount} messages in this conversation`} > - + {email.threadCount} )} {email.importance === "high" && ( - + )} {email.hasAttachments && ( - + )} {email.isFlagged && ( - + )} {email.isStarred && ( - + )} {timeLabel(email.receivedAt)} @@ -575,13 +558,9 @@ export function EmailList({ provider. Tapping it also works if auto-load hasn't fired. */} {(hasMore || canPageProvider) && (
- +
)} @@ -730,11 +709,11 @@ function ContextMenu({
) : ( <> - run(() => onReply("reply"))} /> - run(() => onReply("reply-all"))} /> - run(() => onReply("forward"))} /> + run(() => onReply("reply"))} /> + run(() => onReply("reply-all"))} /> + run(() => onReply("forward"))} /> run(onFix)} /> @@ -743,18 +722,18 @@ function ContextMenu({ )} run(onAddToTasks)} /> {snoozedView ? ( run(() => onSnooze(null))} /> ) : ( - + {snoozePresets().map((p) => ( {open && (
- +
@@ -1020,7 +999,7 @@ function CheckboxSquare({ }` }`} > - {checked ? : indeterminate ? : null} + {checked ? : indeterminate ? : null} ); } @@ -1065,12 +1044,8 @@ function ToolbarBtn({ onClick?: (e: React.MouseEvent) => void; }) { return ( - + ); } diff --git a/workbench/control_plane/src/app/email/components/EmailPreviewModal.tsx b/workbench/control_plane/src/app/email/components/EmailPreviewModal.tsx index a4c647bbb..4f5ea12e4 100644 --- a/workbench/control_plane/src/app/email/components/EmailPreviewModal.tsx +++ b/workbench/control_plane/src/app/email/components/EmailPreviewModal.tsx @@ -1,7 +1,7 @@ "use client"; +import Icon from "@/components/Icon"; import { useEffect, useState } from "react"; -import { Loader2, Paperclip, X } from "lucide-react"; import { Email } from "../lib/types"; import { getEmail } from "../lib/api"; import { fullDateLabel } from "../lib/utils"; @@ -87,13 +87,13 @@ export function EmailPreviewModal({ className="text-muted-foreground hover:text-foreground flex-shrink-0" title="Close (Esc)" > - +
{loading && !e ? (
- Loading… + Loading…
) : err ? (
{err}
@@ -111,7 +111,7 @@ export function EmailPreviewModal({ key={a.id} className="inline-flex items-center gap-1 text-[10px] px-2 py-1 rounded-md border border-border text-muted-foreground" > - {a.filename} + {a.filename} ))}
diff --git a/workbench/control_plane/src/app/email/components/EmailToolbar.tsx b/workbench/control_plane/src/app/email/components/EmailToolbar.tsx index 2e771a1dc..360d1d0e3 100644 --- a/workbench/control_plane/src/app/email/components/EmailToolbar.tsx +++ b/workbench/control_plane/src/app/email/components/EmailToolbar.tsx @@ -1,11 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import AppIcon, { themedIcon } from "@/components/Icon"; import { useState } from "react"; -import { - Pencil, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, - Flag, Star, MailOpen, Mail, Tag, Printer, MoreHorizontal, X, - ListChecks, -} from "lucide-react"; import { useEmailStore, isRealFolder } from "../lib/emailStore"; import { LabelMenu } from "./LabelMenu"; @@ -45,12 +42,12 @@ export function EmailToolbar() { {selectedIds.size} selected
- bulkUpdateSelected({ isRead: true })} /> - bulkUpdateSelected({ isRead: false })} /> - bulkUpdateSelected({ isFlagged: true })} /> - bulkUpdateSelected({ folder: "archive" })} /> - bulkDeleteSelected()} /> - clearEmailSelection()} /> + bulkUpdateSelected({ isRead: true })} /> + bulkUpdateSelected({ isRead: false })} /> + bulkUpdateSelected({ isFlagged: true })} /> + bulkUpdateSelected({ folder: "archive" })} /> + bulkDeleteSelected()} /> + clearEmailSelection()} />
); } @@ -58,35 +55,31 @@ export function EmailToolbar() { return (
{/* Compose — always available */} - + {selectedEmail ? ( <> - setViewerCommand("reply-all")} /> - setViewerCommand("reply")} /> - setViewerCommand("forward")} /> + setViewerCommand("reply-all")} /> + setViewerCommand("reply")} /> + setViewerCommand("forward")} /> captureEmailToTasks(selectedEmail.id)} /> - updateEmail(selectedEmail.id, { folder: "archive" })} /> - deleteEmail(selectedEmail.id)} /> + updateEmail(selectedEmail.id, { folder: "archive" })} /> + deleteEmail(selectedEmail.id)} /> {/* Move to folder */}
- setShowMove((v) => !v)} /> + setShowMove((v) => !v)} /> {showMove && ( <>
setShowMove(false)} /> @@ -114,26 +107,26 @@ export function EmailToolbar() { updateEmail(selectedEmail.id, { isFlagged: !selectedEmail.isFlagged })} /> updateEmail(selectedEmail.id, { isStarred: !selectedEmail.isStarred })} /> updateEmail(selectedEmail.id, { isRead: !selectedEmail.isRead })} /> {/* Label */}
- setShowLabel((v) => !v)} /> + setShowLabel((v) => !v)} /> {showLabel && ( <>
setShowLabel(false)} /> @@ -146,10 +139,10 @@ export function EmailToolbar() { - window.print()} /> + window.print()} /> {/* More */}
- setShowMore((v) => !v)} /> + setShowMore((v) => !v)} /> {showMore && ( <>
setShowMore(false)} /> diff --git a/workbench/control_plane/src/app/email/components/LabelChip.tsx b/workbench/control_plane/src/app/email/components/LabelChip.tsx index d6d85ab83..cdc81a42c 100644 --- a/workbench/control_plane/src/app/email/components/LabelChip.tsx +++ b/workbench/control_plane/src/app/email/components/LabelChip.tsx @@ -1,6 +1,6 @@ "use client"; -import { Check, Tag } from "lucide-react"; +import Icon from "@/components/Icon"; import { LABEL_PALETTE, chipColors, @@ -63,7 +63,7 @@ export function LabelChip({ interactive ? "cursor-pointer hover:opacity-90" : "" } ${className}`} > - {icon && } + {icon && } {name} ); @@ -122,7 +122,7 @@ export function LabelColorGrid({ }} className="w-5 h-5 rounded-full flex items-center justify-center hover:scale-110 transition-transform" > - {selected && } + {selected && } ); })} diff --git a/workbench/control_plane/src/app/email/components/LabelMenu.tsx b/workbench/control_plane/src/app/email/components/LabelMenu.tsx index 917bde420..5180abb4a 100644 --- a/workbench/control_plane/src/app/email/components/LabelMenu.tsx +++ b/workbench/control_plane/src/app/email/components/LabelMenu.tsx @@ -1,7 +1,7 @@ "use client"; +import Icon from "@/components/Icon"; import { useState } from "react"; -import { Check, Plus } from "lucide-react"; import { Email } from "../lib/types"; import { useEmailStore } from "../lib/emailStore"; import { presetForLabel, presetHex, deterministicPreset } from "../lib/labelColors"; @@ -82,7 +82,7 @@ export function LabelMenu({ : "border-muted-foreground/40" }`} > - {on && } + {on && } {name} @@ -135,7 +135,7 @@ export function LabelMenu({ title="Create label" className="text-primary hover:opacity-80 disabled:opacity-40" > - +
{pickNew && ( diff --git a/workbench/control_plane/src/app/email/components/MailboxActions.tsx b/workbench/control_plane/src/app/email/components/MailboxActions.tsx index 2a5174258..8fc4ebd1e 100644 --- a/workbench/control_plane/src/app/email/components/MailboxActions.tsx +++ b/workbench/control_plane/src/app/email/components/MailboxActions.tsx @@ -1,7 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useState } from "react"; -import { AlertTriangle, RefreshCw, Settings2, RotateCw, Eraser, Loader2 } from "lucide-react"; import { Email } from "../lib/types"; import { useEmailStore } from "../lib/emailStore"; import { resyncAccount } from "../lib/api"; @@ -48,29 +49,19 @@ export function MailboxActions({ selectedEmail }: { selectedEmail: Email | null return (
- + } className="rounded"> + + - + } disabled={!selectedEmail} title="Mark as spam" className="rounded"> + + {/* Mailbox settings (gear) */}
@@ -83,7 +74,7 @@ export function MailboxActions({ selectedEmail }: { selectedEmail: Email | null }`} title="Mailbox settings" > - + {showSettings && ( <> @@ -101,9 +92,9 @@ export function MailboxActions({ selectedEmail }: { selectedEmail: Email | null className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-secondary transition-colors disabled:opacity-50" > {resyncing === "full" ? ( - + ) : ( - + )} Resync mailbox @@ -118,9 +109,9 @@ export function MailboxActions({ selectedEmail }: { selectedEmail: Email | null className="w-full flex items-start gap-2 px-3 py-2 text-left hover:bg-secondary transition-colors disabled:opacity-50" > {resyncing === "purge" ? ( - + ) : ( - + )} Hard resync @@ -137,7 +128,7 @@ export function MailboxActions({ selectedEmail }: { selectedEmail: Email | null disabled={!selectedAccountId || syncing} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-secondary transition-colors disabled:opacity-50" > - + Sync new mail now {resyncMsg && ( diff --git a/workbench/control_plane/src/app/email/components/MessageContent.tsx b/workbench/control_plane/src/app/email/components/MessageContent.tsx index 186f4cf2d..68d8ba256 100644 --- a/workbench/control_plane/src/app/email/components/MessageContent.tsx +++ b/workbench/control_plane/src/app/email/components/MessageContent.tsx @@ -1,8 +1,8 @@ "use client"; +import Icon from "@/components/Icon"; import { useEffect, useMemo, useRef, useState } from "react"; import DOMPurify from "dompurify"; -import { ImageOff, MoreHorizontal } from "lucide-react"; import { splitQuotedHtml, splitQuotedText } from "../lib/quoting"; interface MessageContentProps { @@ -95,7 +95,7 @@ function QuoteToggle({ : "border-border bg-secondary text-muted-foreground hover:bg-secondary/70 hover:text-foreground" }`} > - + ); } @@ -224,7 +224,7 @@ function HtmlFrame({ html, quoted = false }: { html: string; quoted?: boolean }) {sanitized?.hasRemote && !showImages && (
- + Remote images are blocked to protect your privacy.
{loading ? (
- Loading… + Loading…
) : err ? (
{err}
@@ -166,15 +164,15 @@ function TimelineRow({ } function rowIcon(ev: MessageTimelineEvent): { - icon: typeof Inbox; + icon: ThemedIcon; tint: string; } { - if (ev.kind === "received") return { icon: Inbox, tint: "text-primary" }; + if (ev.kind === "received") return { icon: themedIcon("Inbox"), tint: "text-primary" }; if (ev.kind === "skipped") - return { icon: MinusCircle, tint: "text-muted-foreground" }; + return { icon: themedIcon("MinusCircle"), tint: "text-muted-foreground" }; if (ev.status === "FAILED" || (ev.action_errors?.length ?? 0) > 0) - return { icon: AlertTriangle, tint: "text-destructive" }; - return { icon: CheckCircle2, tint: "text-emerald-500" }; + return { icon: themedIcon("AlertTriangle"), tint: "text-destructive" }; + return { icon: themedIcon("CheckCircle2"), tint: "text-emerald-500" }; } function rowTitle(ev: MessageTimelineEvent): string { diff --git a/workbench/control_plane/src/app/email/components/QuickFilters.tsx b/workbench/control_plane/src/app/email/components/QuickFilters.tsx index 3eee9c6ee..173756e69 100644 --- a/workbench/control_plane/src/app/email/components/QuickFilters.tsx +++ b/workbench/control_plane/src/app/email/components/QuickFilters.tsx @@ -1,7 +1,7 @@ "use client"; +import Icon from "@/components/Icon"; import { useEffect, useState } from "react"; -import { ListFilter } from "lucide-react"; import { useEmailStore } from "../lib/emailStore"; import { SearchFilter, addFilter, filterKey } from "../lib/searchFilters"; import { chipColors } from "../lib/labelColors"; @@ -149,7 +149,7 @@ export function QuickFilters() { return (
- + {visible.map(({ label, facet, f }) => { const active = isActive(f); const c = f.kind === "tag" ? chipColors(f.value, labelColors) : null; diff --git a/workbench/control_plane/src/app/email/components/SearchBar.tsx b/workbench/control_plane/src/app/email/components/SearchBar.tsx index 5c5100773..4b277428d 100644 --- a/workbench/control_plane/src/app/email/components/SearchBar.tsx +++ b/workbench/control_plane/src/app/email/components/SearchBar.tsx @@ -1,7 +1,8 @@ "use client"; +import Button from "@/components/ui/Button"; +import Icon from "@/components/Icon"; import { useEffect, useMemo, useRef, useState } from "react"; -import { Search, X, ChevronDown, SlidersHorizontal, Sparkles, Check } from "lucide-react"; import { useEmailStore, FOLDER_ALL } from "../lib/emailStore"; import { SearchFilter, @@ -114,14 +115,10 @@ export function SearchBar() { > {/* ── Scope dropdown: says WHERE we're searching ── */}
- + + {scopeOpen && ( <>
setScopeOpen(false)} /> @@ -148,7 +145,7 @@ export function SearchBar() { > {o.label} {scope === o.key && ( - + )} ))} @@ -158,7 +155,7 @@ export function SearchBar() {
- + {/* ── Pills + input share a scrollable row ── */}
@@ -182,7 +179,7 @@ export function SearchBar() { c ? "hover:bg-black/15" : "hover:bg-primary/20" }`} > - + ); @@ -207,19 +204,14 @@ export function SearchBar() { title="Results ranked by meaning, not just keywords" className="flex-shrink-0 inline-flex items-center gap-0.5 text-[9px] font-medium text-primary bg-primary/10 rounded px-1 py-0.5" > - Smart + Smart )} {active && ( - + )} {/* ── Filter menu: the discoverable half of the typed grammar ── */} @@ -234,7 +226,7 @@ export function SearchBar() { : "text-muted-foreground hover:text-foreground hover:bg-secondary" }`} > - + {filterOpen && ( <> @@ -433,7 +425,7 @@ function FilterMenu({ /> {name} - {has(f) && } + {has(f) && } ); }) diff --git a/workbench/control_plane/src/app/email/components/SignatureEditor.tsx b/workbench/control_plane/src/app/email/components/SignatureEditor.tsx index 9e970bc4b..ed5fa0bc8 100644 --- a/workbench/control_plane/src/app/email/components/SignatureEditor.tsx +++ b/workbench/control_plane/src/app/email/components/SignatureEditor.tsx @@ -1,12 +1,8 @@ "use client"; +import Icon from "@/components/Icon"; import { useCallback, useEffect, useRef } from "react"; import DOMPurify from "dompurify"; -import { - AlignCenter, AlignLeft, AlignRight, Bold, Code2, Eye, Image as ImageIcon, - Italic, Link2, Link2Off, List, ListOrdered, Minus, Redo2, RemoveFormatting, - Strikethrough, Table as TableIcon, Underline as UnderlineIcon, Undo2, Upload, -} from "lucide-react"; import { EditorContent, ReactNodeViewRenderer, NodeViewWrapper, useEditor, useEditorState, type Editor, type NodeViewProps, @@ -287,23 +283,23 @@ export function SignatureEditor({ {/* Toolbar */}
chain().undo().run()}> - + chain().redo().run()}> - + chain().toggleBold().run()}> - + chain().toggleItalic().run()}> - + chain().toggleUnderline().run()}> - + chain().toggleStrike().run()}> - + {/* Font size + colour ride on — email-client-safe. */} @@ -342,35 +338,35 @@ export function SignatureEditor({ chain().setTextAlign("left").run()}> - + chain().setTextAlign("center").run()}> - + chain().setTextAlign("right").run()}> - + chain().toggleBulletList().run()}> - + chain().toggleOrderedList().run()}> - + - + {s?.link && ( chain().extendMarkRange("link").unsetLink().run()}> - + )} - + fileRef.current?.click()}> - + chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()} > - + chain().setHorizontalRule().run()}> - + chain().unsetAllMarks().unsetTextAlign().run()}> - +
@@ -450,8 +446,8 @@ export function SignatureEditor({ }} > - Edit HTML source - + Edit HTML source +