WS-27: one design language for /projects and /tasks, the themed categorical ramp, the house shell, and the last two Projects tenancy residues - #418
Merged
Conversation
H2 is on main and routes/projects is fully converted, so Projects feature work
is unblocked. Four dispatchable tickets plus one banked:
- aa the two tenancy residues this app owns — run_lifecycle_sweep sweeps every
tenant's projects with no predicate (D27 finding 2, MT-1d's named site)
and agent_dispatch is the H2 ratchet's one Projects exemption. Closing
both means Projects is not what gates WS-29's phase-4 promotion.
- ab view ergonomics: peek escalation + Esc-returns-focus, dirty-view
affordances, palette action registry with generated shortcuts help.
- ac calendar week layout, per-day quick-add, exact overflow.
- ad Tasks<->Projects continuity round 2 — the divergences the first backport
recorded and deliberately left.
- ae export / delta-sync / small columns — minted, not dispatched.
None needs a migration, so R1 costs this wave nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
… and /tasks
Owner-directed: the Kanban boards, lists, cards and colour schemes of the two
task apps looked like two products. They were.
Three vocabularies existed and a fourth fact was stored and thrown away:
* app/tasks/lib/stageColors.ts — a real accent system (dot/soft/text/bar),
resolved by name keyword then position, driving Tasks' board columns, list
group headers and status pill;
* app/projects/lib/tags.ts — a six-name chip palette, tags only;
* pm_task_statuses.color — stored since migration 146, exposed as
StatusRow.color, and RENDERED NOWHERE. Every Projects board column drew the
same bg-muted, so Done and Backlog were the same grey while the Tasks board
next door was colour-coded.
src/lib/statusAccent.ts is now the one palette, resolved by precedence:
stored colour → status category (Projects' six, which Tasks does not have) →
name keyword (Tasks' user-named stages; regexes ported unchanged) → positional,
with lastIsDone as Tasks' own rule. Tokens only; class strings are the existing
ones, so Tasks renders identically and its tests say so.
Consuming it:
* Projects board — accent cap, tinted header, dot and text tone per column;
swimlane column headers too. A Done lane now reads green.
* Projects list group headers — the left bar accent Tasks' list already had.
* Projects list + table status cells — the shared StatusChip pill, not a bare
grey word; the board card carries it too when the board is off the status
axis (Tasks' showStage rule).
* projects/lib/tags.ts chipClass delegates; TAG_COLORS is now ACCENT_HUES.
* tasks/lib/stageColors.ts is a thin adapter; StatusPill's body is StatusChip.
src/components/TaskCardShell.tsx is the shared box. Projects' card was
rounded-md/bg-background/p-2 — the page colour, i.e. a card-shaped hole in the
column — against Tasks' rounded-lg/bg-card/p-3 with a shadow lift. Tasks' wins;
each app still feeds its own facts and Projects' shown_fields gating is intact.
R7: src/lib/statusAccent.test.ts pins the precedence, every category and colour
name, unknown input falling through rather than throwing, and the byte-identical
Tasks/tag class strings. src/lib/sharedTaskUi.test.ts fences the seam — each
shared thing declared once, both apps reaching it, shims staying shims, and no
second name→class palette in either app.
Found and NOT swept in: app/crm/lib/board.ts holds a third such map for pipeline
stages. Exempted in the test with the reason; it is a CRM decision.
npx tsc --noEmit clean; npx vitest run 1381 passed (was 1278); theme conformance
208 passed with no baseline change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
H4, done inside Projects, so this app is not what gates WS-29's phase-4 promotion. Neither site was converted to the ambient `tenant_session()` — for a background unit that IS the inheritance H4 forbids. Both take an EXPLICIT tenant from a stored fact and refuse without one. 1. `run_lifecycle_sweep` (D27 (2), MT-1d's named site). Signature gains a required `organization_id`; a blank one raises `TenantUnbound` before any statement is issued; the roots query gains `AND organization_id = CAST(:org AS uuid)`. Before this it was `SELECT * FROM pm_projects WHERE parent_project_id IS NULL` on a schedule — one customer's workflow archiving and closing everybody's work. `_pm_lifecycle_sweeper` resolves the tenant on an unbound session from the workflow OWNER (`workflows.owner_email` -> `app_user.organization_id`; `workflows` has no `organization_id` column until H3 phase 1, verified against 132_workflows.sql and a live catalog) and then opens `tenant_session(org)` — auto_lead's shape, re-derived, not a second one. 2. `agent_dispatch` (the H2 ratchet's one Projects exemption, now removed). `set_assignees` reads the task's own `organization_id` inside the request's bound session and puts it on `pm.task.assigned`; `on_event`, `_run_and_record` and `_record_outcome` all bind that value explicitly. An event without one is refused with a WARNING and no write. Two spec clauses struck as unbuildable/wrong, recorded in §9.2: * "records the refusal on the task timeline" — the timeline is `pm_activities`, tenant data, so writing it needs precisely the unbound session being refused (and under phase 4 would write nothing). It is a log line. * MT-1d's "it needs a per-tenant loop" — that would be one tenant's scheduled workflow acting for every other tenant. The loop is over workflows. Also fixed: `test_workflows_automation_health._install` patched "whichever" seam a module holds and stopped patching `_get_db` once `service` gained `_tenant_session`, sending four tests at a real Postgres. Ratchets: `routes/projects` 2 unbound sites -> 0 and no H2 exemption at all; `H2_BASELINE_ELSEWHERE` unchanged at 111 (the sweeper trades its unbound session for the resolver, which must stay unbound). R8: `tests/live/live_ws27aa.py` — 23 checks on Postgres 16, two orgs with identical policies and identically stale tasks, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…ks surfaces
The interaction half of the continuity backport — the three divergences round 1
recorded and left.
SELECTION (done-when 1). src/lib/selection.ts holds the grammar: clickSelect,
range, toggle, prune, allSelected. A plain click toggles and becomes the anchor;
a shift-click adds the inclusive range and leaves the anchor put; shift never
removes. stepCursor's private copy of the range walk is gone — it reads this,
so the mouse's shift-click and the keyboard's shift-sweep are one implementation
again. /projects' page and the /tasks store both drive it; the store gained
`selectAnchor` and `extendSelection`, and every /tasks surface passes its own
render order (the store cannot know it) plus the shift key.
/tasks' modal select-mode is KEPT, with the reason in ItemList.tsx: selectMode
changes what a CLICK MEANS. A /projects row has a dedicated checkbox cell, so
click-opens and box-selects are two targets. A /tasks row is a TaskCard whose
whole surface is the open affordance and which carries its own controls; a
permanent checkbox would take the drag-grip gutter the manual sort needs, or
make one gesture mean two things. The mode is the entry; the grammar inside it
has converged.
BOARD CHROME (done-when 2). Drop-gap reorder beats append-on-drop: /projects
accepted a drop anywhere and appended, so dragging a card two rows up dropped it
at the bottom. Now components/DropGap.tsx + lib/boardDrop.ts (gapKey,
dropIndexFor) are shared and consumed by both boards; dropIndexFor carries the
downward intra-group off-by-one that was buried in taskStore.reorderItem, which
now calls it. A drop on the column body still appends — that is the honest
reading of "somewhere in this column". Accent caps went the other way in the
previous commit. Swimlanes stay /projects-only, reason in tasks/TaskBoard.tsx:
/tasks' second axes are COMPUTED (priority/mode from flags x due date), so a
lane grid would be a grid whose cells refuse every drop.
FLAT SURFACES (done-when 3). tasks/components/FlatList.tsx lifts the ungrouped
views (Done/Someday/Archive/Engage/Priority) out of ItemList and gives them the
shared cursor, flash and selection — a third of the app's surfaces where the
arrow keys did nothing. WaitingForView too. Per-view quick-add is
lib/quickAdd.viewQuickAdd, on the module's existing null grammar: Someday
incubates, Done logs a completed entry, and Waiting/Archive REFUSE with the
reason in code (a create can set the WAITING bucket but not the delegation, so
the box would file under "Unassigned" — a sibling group). The Inbox's local j/k
walk is retired for lib/cursor; the triage keys stay local and the shortcuts
sheet now says up/down. Its selection also became one SelectionState value
rather than two, so `x` can no longer write a fresh set against a stale anchor.
R7 (done-when 4): src/lib/sharedTaskUi.test.ts grew rows for selection, DropGap
and boardDrop; src/lib/{selection,boardDrop}.test.ts pin the transitions,
including the anchor-widening case and the downward-drag off-by-one.
Done-when 5 (calendar asymmetry) stays out of scope and recorded in the spec.
R4: project_management_app.md 9.2 WS-27ad marked BUILT with the as-built seam,
both kept divergences and their reasons, and three findings not swept in.
npx tsc --noEmit clean; npx vitest run 1407 passed (was 1278 at branch point);
theme conformance 208 passed, no baseline change; eslint clean on every changed
file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…k-management-a1f6zd
The shared vocabulary landed, but two of the four default lanes still drew
different colours in /projects and /tasks -- which is the divergence the
module was built to end, reintroduced one layer down.
CATEGORY_HUES had been written to match the seeded colours (To do blue,
In progress amber) while the ported keyword rules said gray and blue. Since
a stored colour outranks the category, every uncustomised project kept
overriding the shared vocabulary:
lane /projects /tasks
Backlog gray gray ok
To do blue gray mismatch
In progress amber blue mismatch
Done green green ok
The semantics belong to the token vocabulary, not to whatever four rows a
migration inserts, so the category map now follows the keywords and the seed
follows the category map. bg-primary is this UI's "active" tone everywhere
else, so in_progress is blue; nothing has started in backlog or todo, so both
stay muted -- which is /tasks' existing behaviour, and matching it is the ask.
Fences, both named in the code they guard:
- test_category_and_keyword_agree (statusAccent.test.ts) -- every category and
a name a person would plausibly type for it resolve to one hue.
- test_seed_status_colours_match_the_shared_vocabulary (test_projects_routes)
-- reads CATEGORY_HUES out of the TypeScript rather than mirroring it,
because a mirror is the thing that goes stale and then lies.
- test_every_seeded_category_is_one_the_shared_vocabulary_colours -- the
companion direction; an uncoloured category renders grey, which is the
pre-WS-27ad failure wearing a different hat.
Known and deliberate: backlog and todo now share a hue, so a board carrying
both draws two muted lanes. That is what /tasks does today. An owner who wants
them distinct has a working lever for the first time -- status.color outranks
this map and is finally rendered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
"I want the UI for the projects to match the theming configuration used in the Command Center... ensuring future development considers it." Recorded where future work actually reads it rather than in a new document nobody loads: workbench/control_plane/AGENTS.md (auto-loaded via its CLAUDE.md whenever UI code is touched) and one bullet in the root always-loaded briefing. Four rules added on top of the existing three, each one written because it was already broken somewhere in the tree: 4. One vocabulary per concept, shared -- statusAccent.ts is the single place a status, tag, column, group header or pill becomes a hue. Do not add a second palette; extend the shared one. 5. A category and a name must resolve to the same colour, or the same lane draws two colours in two apps. Seeded data counts as a UI decision, because a stored colour outranks a derived one. 6. House tokens, not synonyms: bg-primary/10 for active (not bg-accent), radius from --radius (rounded-xl ignores Graphite and Material). 7. Categorical hues belong to the theme too -- bg-sky-500/10 passes the conformance regex, so that one is on the author, not on CI. Also states plainly what CI does not cover: there is no structural or layout test in this tree, so cross-app continuity is measured by switching the theme to Fluent, Material and Graphite and looking -- at the surface changed AND its neighbour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
… mobile UI at all `/projects` was the only cross-cutting app in the tree with no mobile layout: page.tsx imported neither useViewMode nor useMobileDrawer, so a phone got a fixed 256px nav beside a five-mode canvas — and a third column the moment a task opened — inside AppShell's pb-nav scroller. AppShell enumerated six `is<App>Page` branches and none of them was Projects. - Mobile branch: one pane. ProjectTree + My work become a drawer sheet, the five view modes a second sheet, and an opened TaskPanel a full-screen surface (its `max-w-md` is a docked-column width, lifted by the shell). - AppShell: additive `isProjectsPage` const and a Projects · Views · Search tab set beside the existing ones — exactly what the desktop layout owns and a phone cannot reach (a 240px rail, a toolbar of five modes, a ⌘K palette with no keyboard). Notifications stay in the page's title row: the bell is self-anchored and has no external open control. - Desktop: the house shell — a slim h-10 bar (rail toggle · divider · title · app actions) over a collapsible rail at Tasks' w-60; the six-purpose header splits into a title row and an action row. - Three bg-accent sites → bg-primary/10 text-primary (My work, mode switch, selected tree node). FilterBar/TaskList/MyWork/SearchPalette are a later slice's files and keep theirs. - "Loading projects…" de-duplicated into LOADING_COPY behind one renderState() seam that also carries the empty and error surfaces; the failure strip no longer wears bg-muted, the token for quiet. Two seams left marked `── SEAM (WS-27ag) ──`: the shared <Toast> mount point inside `overlays`, and renderState() as the single call site <EmptyState> replaces. Neither is built here. Two rules learned and written into the code: the shell drawer holds a SNAPSHOT (re-inject on every change to what the sheet draws; keep every callback a useState setter or the re-injection loops through the drawer's context), and dismissing the drawer from the outside must clear the page's sheet state or the next data change reopens it. Both recorded in workbench/AGENTS.md. Advisory, not fenced: this tree has no structural or layout test at all — nothing checks that an app has a mobile branch or that a cc-mobile-nav detail string agrees at both ends. tsc --noEmit, 1278 vitest cases, the theme suite and a production next build are green; no browser was runnable here, so the phone-viewport and four-theme pass is owed at review (spec §11.20). R4: project_management_app.md status header + §11.20. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…management-a1f6zd
Conflict resolved in tasks/components/TaskCard.tsx: the tip's <TaskCardTitle> shared component wins over the local <p>, which my type-scale codemod had rewritten. The codemod's substitution is therefore moot in that one spot — TaskCardShell owns the title size now. Re-measured against the merged tree: the tip introduced no raw palette classes and did not touch src/lib/theme/, so PALETTE_DEBT is unchanged.
…s colour debt it retires The problem: `src/app/tasks` carried 142 raw Tailwind palette classes across 13 files (`bg-sky-500/10`, `text-violet-600 dark:text-violet-400`, …), 40 of them in `contextColors.ts` alone — eight fixed accents rendered on every card, every filter option and every facet chip. Switch the org to Material and every other surface followed while those eight stayed put and clashed. The ramp (AGENTS.md rule 7, which named it before it existed): * `--cat-1` … `--cat-8` in all four theme manifests, both modes — 64 values, required by the type so a missing one is a compile error. Hues picked by maximising the WORST pairwise CIE-Lab separation across all eight theme/mode combinations (min ΔE ≈ 27 on Graphite dark; evenly spaced sets scored ≈ 20). Lightness tuned per theme/mode/hue so each clears AA against that theme's own card AND background, with headroom — no KNOWN_SHORTFALLS entry was needed. * Bridged into Tailwind in `globals.css`, mirrored in the no-JS fallback. * `src/lib/categorical.ts` owns the class strings and the name → slot hash, beside `statusAccent.ts` and deliberately not inside it (rule 4): a status resolves to a semantic tone because its hue is information, a category to a ramp slot because its hue is only an identity. * `app/tasks/lib/contextColors.ts` keeps only the hand-assigned @context slots and delegates the rest — the shape `stageColors.ts` has over `statusAccent`. Also retired: `TaskCard`'s "Deep work" badge and `ListColumns`' MODE_TONE onto semantic tokens; `SourceBadge` is now a wrapper over `<Badge tone icon>`, so it picks up Graphite's uppercase and Material's tracking like every other badge; 167 off-grid text sizes across `src/app/tasks` codemodded onto the house scale (`text-[12px]`→`text-xs`, `text-[13px]`→`text-sm`), which also puts them back under the user's density preference. Fences (R7): * `themes.test.ts` — all four themes declare all eight slots in both modes, no duplicates within a mode, light is re-tinted rather than reused, and every slot is bridged into Tailwind. * `contrast.test.ts` — 128 new measured pairs (8 slots × card+background × 8 theme/modes), all at the body-text threshold. * `categorical.test.ts` — slot n draws --cat-(n+1), classes written literally so Tailwind's scanner sees them, hash stable and evenly spread. * `contextColors.test.ts` — a frozen golden table of @context → slot. * `conformance.test.ts` rule 5 (NEW) — raw Tailwind palette classes, per-file baselines that only go down. This is the hole rule 7 said was "on you, not on CI": 952 classes across 77 files, now visible and ratcheted. Fixed along the way, found by its own new fence: `contextSlot` stripped the `@` before trimming, so `" @office "` missed the keyword map and hashed to a different colour than `"@office"`.
Mints the WS-27af and WS-27ag rows the built work had no target for (R4 had nothing to hit: af's ramp and the design system are owned by workbench/control_plane/DESIGN_SYSTEM.md and AGENTS.md, not by any spec in project-docs), and updates the WS-27 board row with the wave. Also corrects the root briefing: the conformance suite checks FIVE regexes now, not four -- af added the raw-palette rule -- and names src/lib/categorical.ts as the categorical vocabulary. Two things recorded rather than quietly carried: 1. The visual check did not happen. No browser is runnable in the build environment, so neither slice did the phone-viewport pass or the Fluent/Material/Graphite sweep. Both compensated honestly (production build, icon names checked against the theme registry, the ramp rendered to PNGs and inspected, including under a simulated deuteranopia transform -- which found that eight qualitative hues collapse to about four under dichromacy, a limit now written into the code rather than left implicit). None of that substitutes for looking at the running app. The gate is open and the docs say so. 2. A contradiction needing an owner ruling: ad standardised the SHARED card title on text-[13px], choosing /tasks' size as the common one; af then established the house scale and removed every other off-grid size. The one survivor is now in a component both apps render. Changing it repaints Projects too, so it was left rather than settled by whichever slice touched it last. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
…dy chose Without this, the shared colour vocabulary reaches NO existing project. pm_task_statuses.color has been stored since migration 146 and rendered nowhere until WS-27ad, so these values were never seen by a human and never chosen by one -- they are whatever _SEED_STATUSES inserted at creation. When the column finally rendered, a stored colour was made to outrank the derived one (that is what lets an owner choose), so a seed that disagrees with the shared vocabulary silently overrides it. The old seed disagreed on two lanes: To do was blue where the vocabulary says gray, In progress amber where it says blue. The seed has moved to agree -- but a seed governs only projects created after the deploy, so every project that already exists would keep rendering two of its four lanes differently from /tasks. Which is the exact divergence the whole change set was written to end. Each UPDATE matches the FULL pre-change tuple -- name AND category AND the old colour -- so a renamed lane, a recategorised one, or one whose colour a human picked is skipped. A human's decision survives even when it coincidentally equals the value being replaced, because then there is nothing to replace. Data, not schema: no column added, altered or dropped, so R6 has nothing to bind and old code meets no new shape. Idempotent by construction -- each WHERE selects on the old colour and writes a different one, so a second run matches zero rows. Deliberately not qualified by organization_id: it is a migration, run as the schema owner, and the defect is identical in every tenant. R8 -- verified against a real database, not the text. Four rows seeded in the four states, migration applied TWICE: To do / todo / blue -> gray repainted In progress / in_progress / amber -> blue repainted Up next / todo / blue -> blue skipped (renamed) In progress / in_progress / violet -> violet skipped (owner chose) Second run changed nothing. Probe rows removed; the pre-existing fixture rows were left intact (the first cleanup attempt hit an FK on one and rolled back, which is why the second is narrowed to the two probe projects by id). Fences in test_projects_migration.py: the repaint matches the whole seed tuple and not just the name; it is idempotent by construction; its target colours equal what CATEGORY_HUES derives (read out of the TypeScript, not mirrored); and the companion direction -- a seeded lane that disagrees with the vocabulary and is not repainted would stay divergent on every existing project, invisibly, because new ones look right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmFScimSbeyHcLdut7RT4W
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Driven by two owner directives:
Four slices: aa (tenancy residues), ad (shared visual language), af (themed categorical ramp), ag (house shell + mobile).
ab/acwere deferred behind the UI work by owner priority.deploy.ymltriggers on push tomain, and the deploy auto-applies migrations before restarting services. Merging this therefore applies 164, 165, 166 (already outstanding on the box) and 167 (new here) to production, in one go. Per R6 there is no rollback — only roll forward or restore.167 is data-only (two narrow
UPDATEs, no schema change, idempotent by construction). 164–166 are additive schema from the previous wave. Worth a glance atschema_migrationsfirst to confirm what the box actually has.What a user sees
The Projects board was grey because it was throwing away colour it already had.
pm_task_statuses.colorhas been stored since migration 146, carried to the frontend asStatusRow.color— and rendered nowhere. Every column drewbg-mutedwhile the Tasks board next door was colour-coded per stage.There were three colour vocabularies that never met: a real accent system in
tasks/lib/stageColors.ts, a tag-onlychipClassinprojects/lib/tags.ts, and the stored column nothing drew.Now there is one —
src/lib/statusAccent.ts— resolving by precedence: stored colour → category → name keyword → position. Projects knows what a lane means (STATUS_CATEGORIES); Tasks can only read what it is called. Both routes now land on the same hue.Also shipped:
TaskCardShellandStatusChipconsumed by both apps, the drop-gap reorder (Projects previously appended on drop, so dragging a card two rows up sent it to the bottom), one selection grammar, and the shared cursor on the Tasks flat lists — a third of that app's surfaces where the arrow keys did nothing./projectshad no mobile UI at all. It imported neitheruseViewModenoruseMobileDrawerand had noAppShellbranch, so a phone got a fixed 256px sidebar beside the canvas plus a third column when a task opened. It now has the single-pane branch, the tree in the drawer, a bottom-nav tab set, the slimh-10house bar over a collapsible rail, and the seven-way header split into three rows. The error strip also stopped rendering failures as calmbg-muted.The theming engine now covers categorical hues.
--cat-1 … --cat-8in all four manifests in both modes (64 values), bridged to Tailwind, with slots chosen by hashing an item's name rather than its array position./taskscarried 142 raw Tailwind palette classes that ignored the theme entirely;/projectscarried zero.The defect caught in review
The shared vocabulary landed, and the boards still would not have matched.
CATEGORY_HUEShad been written to mirror the seeded colours rather than to agree with Tasks:Two of four default lanes still divergent — the original problem one layer down. Fixed by making the category map follow the keyword semantics and moving the seed to match, since a stored colour outranks a derived one and a disagreeing seed silently overrides the shared vocabulary on every uncustomised project.
And the seed only governs new projects, so migration 167 repaints existing ones — matching the full pre-change tuple (name and category and old colour), so a renamed lane or an owner-chosen colour is skipped. Verified against a real database, applied twice: two repainted, two correctly skipped, second run a no-op.
Tenancy (closes the last Projects blocker on RLS)
run_lifecycle_sweepswept every tenant's projects with no predicate on a scheduled path, andagent_dispatchwas the H2 ratchet's one Projects exemption. Both closed: the sweep takes a requiredorganization_idand refuses without one, resolved from a stored fact (workflows.owner_email→app_user, never request input); dispatch carries the task's ownorganization_idon thepm.task.assignedpayload. Proven on live Postgres — 23 checks including a two-org isolation case where org A's sweep leaves org B's stale tasks untouched, with each guard mutation-measured red.routes/projectsis now at zero unbound sites and the exemption is gone.Fences
conformance.test.tsgained a fifth rule — raw Tailwind palette classes, per-file baselines that only go down.bg-sky-500/10passed every previous regex (a named class, not a bracket class), which is how ~950 accumulated tree-wide.test_category_and_keyword_agree— every category and a name a person would type for it resolve to one hue.test_seed_status_colours_match_the_shared_vocabulary— readsCATEGORY_HUESout of the TypeScript rather than mirroring it, because a mirror goes stale and then lies.Verification
tscclean · 1586 tests / 73 files (from 1278) · theme suite 353 (from 208) · productionnext buildcompiles and prerenders all 39 pages · backend253 passedon the touched suites, full directory5720 passedwith the two recorded hazard suites deselected.Owed, and not claimed
The visual check did not happen. Playwright cannot install in the build environment, so neither the phone-viewport pass nor the Fluent → Material → Graphite sweep was performed. Compensations: production build, icon names verified against the theme registry, hand-traced z-order, and the ramp's shipped values rendered to PNGs and inspected — including under a simulated deuteranopia transform, which found that eight qualitative hues collapse to about four under dichromacy. No eight-hue palette survives that, so every use pairs the hue with its label and the limit is written into the code. None of it substitutes for looking at the running app.
This matters more than usual because there is no structural or layout test anywhere in this tree — nothing asserts panel counts, shell adoption, mobile branches, or that two apps draw a card the same way.
One contradiction left for the owner rather than settled by coin-flip:
adstandardised the shared card title ontext-[13px], choosing Tasks' size as the common one;afthen established the house scale and removed every other off-grid size. The survivor now sits in a component both apps render, so changing it repaints Projects too.Known and deliberate:
backlogandtodoshare a muted hue, so a board with both draws two grey lanes. That is what/tasksdoes today and matching it was the ask;status.coloris the lever, and it finally works.Generated by Claude Code