Skip to content

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
vjvarada merged 14 commits into
mainfrom
claude/paca-research-task-management-a1f6zd
Aug 10, 2026
Merged

WS-27: one design language for /projects and /tasks, the themed categorical ramp, the house shell, and the last two Projects tenancy residues#418
vjvarada merged 14 commits into
mainfrom
claude/paca-research-task-management-a1f6zd

Conversation

@vjvarada

Copy link
Copy Markdown
Contributor

Driven by two owner directives:

"the appearance of the Kanban board and its lists, color schemes, task cards… Please fix the UI for the shared components."
"I want the UI for the projects to match the theming configuration used in the Command Center… ensuring future development considers it."

Four slices: aa (tenancy residues), ad (shared visual language), af (themed categorical ramp), ag (house shell + mobile). ab/ac were deferred behind the UI work by owner priority.


⚠️ Read before merging — this deploys, and it applies four migrations

deploy.yml triggers on push to main, 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 at schema_migrations first 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.color has been stored since migration 146, carried to the frontend as StatusRow.color — and rendered nowhere. Every column drew bg-muted while 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-only chipClass in projects/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: TaskCardShell and StatusChip consumed 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.

/projects had no mobile UI at all. It imported neither useViewMode nor useMobileDrawer and had no AppShell branch, 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 slim h-10 house bar over a collapsible rail, and the seven-way header split into three rows. The error strip also stopped rendering failures as calm bg-muted.

The theming engine now covers categorical hues. --cat-1 … --cat-8 in 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. /tasks carried 142 raw Tailwind palette classes that ignored the theme entirely; /projects carried zero.

The defect caught in review

The shared vocabulary landed, and the boards still would not have matched. CATEGORY_HUES had been written to mirror the seeded colours rather than to agree with Tasks:

lane /projects /tasks
Backlog gray gray ✅
To do blue gray
In progress amber blue
Done green green ✅

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_sweep swept every tenant's projects with no predicate on a scheduled path, and agent_dispatch was the H2 ratchet's one Projects exemption. Both closed: the sweep takes a required organization_id and refuses without one, resolved from a stored fact (workflows.owner_emailapp_user, never request input); dispatch carries the task's own organization_id on the pm.task.assigned payload. 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/projects is now at zero unbound sites and the exemption is gone.

Fences

  • conformance.test.ts gained a fifth rule — raw Tailwind palette classes, per-file baselines that only go down. bg-sky-500/10 passed 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 — reads CATEGORY_HUES out of the TypeScript rather than mirroring it, because a mirror goes stale and then lies.
  • Four fences on 167: whole-tuple matching, idempotency by construction, targets agreeing with the vocabulary, and the companion direction.

Verification

tsc clean · 1586 tests / 73 files (from 1278) · theme suite 353 (from 208) · production next build compiles and prerenders all 39 pages · backend 253 passed on the touched suites, full directory 5720 passed with 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: 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 survivor now sits in a component both apps render, so changing it repaints Projects too.

Known and deliberate: backlog and todo share a muted hue, so a board with both draws two grey lanes. That is what /tasks does today and matching it was the ask; status.color is the lever, and it finally works.


Generated by Claude Code

claude added 14 commits August 10, 2026 08:48
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
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants