{"releases":[{"id":"430bf67f-d471-408b-bc52-75131e8f6c27","tag":"W2026-25","slug":"w-w2026-25","version":null,"title":"W2026-25 — 16 changes this week","summary":"Auto-published weekly digest. Covers 16 changes from 2026-06-15 merged into main.","status":"published","publishedAt":"2026-06-15T15:59:12.903Z","periodStartsAt":null,"periodEndsAt":"2026-06-15T15:59:12.903Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["auto","weekly"],"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.934Z","entries":[{"id":"700ba84b-b817-43f6-b4c6-8ece4ab96ea4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-reconciliation-cron","type":"added","scope":"storage","summary":"Daily storage drift reconciliation cron — counter vs live-rows / provider inventory, with auto-correction or alert.","body":"Phase 3.5 of the unified Storage + Drive plan. Operationalises\nCLAUDE.md invariant 9: \"Drift reconciliation is the truth. The\ncounter is best-effort denorm; the source of truth is the\nnightly reconcile job.\"\n\n`runReconciliationSweep(deps)` in `@helios/storage-module/jobs`\nwalks every (org, profile) tuple with live `storage_objects`\nrows. For each tuple it computes three numbers:\n\n- `counter_bytes` — `SUM(bytes_used)` across the org's 10 shards\n- `events_agg_bytes` — `SUM(uploaded.bytes) − SUM(deleted.bytes)`\n  from `storage_usage_events`\n- `live_rows_bytes` — `SUM(size_bytes)` from `storage_objects`\n  where `deleted_at IS NULL`\n\nWhen the active driver is the local one (cheap to walk in dev /\nsingle-tenant deploys), it also computes:\n\n- `inventory_bytes` — driver walk via `listObjects + headObject`,\n  bounded at 50_000 keys per tuple\n\nS3-family drivers skip the inventory walk in v1 because naive\n`listObjects + headObject` is O(file count) × roundtrips; the\nPhase 4 work integrates S3 Inventory / GCS Insights / R2 GraphQL /\nAzure Inventory feeds and writes the `source` field accordingly.\n\n**Drift classification** (truth = inventory when walked, else\n`live_rows_bytes`):\n\n| Drift % | Resolution | Action |\n|---|---|---|\n| 0% | `clean` | Row recorded; nothing else |\n| > 0% and < 1% | `event_reconciled` | Acknowledged; no correction |\n| 1–5% | `counter_corrected` | Shards recomputed from live rows |\n| ≥ 5% | `drift_alert_raised` | Surfaced to the cron's caller for the Phase 3.6 operator-alert email subscriber |\n\nCounter correction normalises shard 0 to the recomputed value\nand zeros the other 9 — reads sum across shards so the post-fix\ntotal matches the live rows.\n\nWorker cron at `apps/worker/src/storage-reconciliation-cron.ts`\nruns the sweep daily (24h interval, 10min boot stagger to avoid\npiling on top of the verify cron which uses 5min). Per-tick\nheartbeat record so the operator dashboard's \"last reconciliation\"\ntile stays fresh; status flips to `error` when any tuple raises a\ndrift alert OR a tuple reconcile throws.\n\nEvery run writes a row to `storage_reconciliation_runs` with the\ncounter / events / inventory / drift values + the resolution verdict\n— the `/saas/storage` admin gains a drift-over-time chart for free\n(rendering UI not in this commit).\n\n4 PGlite integration tests:\n- happy path (clean): counter equals live-rows sum, no drift\n- counter-corrected: 2.5% drift triggers shard recompute\n- drift alert: 50% drift raises an alert + counter is NOT touched\n- empty world: zero profiles / zero rows is a no-op\n\nThe matching event class (`storage.reconciliation.drift_alert`)\nexists from Phase 2D; this commit reports newly-raised alerts via\nthe sweep's return value rather than emitting the event directly\n(emission belongs in the cron, not the sweep — Phase 3.6 will\nwire the email-on-drift-alert subscriber).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:29.193Z","updatedAt":"2026-06-16T16:34:29.193Z"},{"id":"3193785a-6533-41da-94c8-0655a41733e7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-attachment-upload-storage-bridge","type":"changed","scope":"support","summary":"Support ticket attachment upload now routes through storage.object.put_url instead of direct driver presign.","body":"Phase 6.1.3 of the unified Storage + Drive plan — second\nbrowser-presign consumer migration after roadmap (`30dbb0bd`).\n\n`support.ticket.attachment.create_upload_url` now calls\n`getAction('storage.object.put_url')` + `invoke()` via a\n`createSystemContext` with `storage:object:write:own`. The\nsysCtx covers the customer-portal case where the actor (a\n`client` role) doesn't carry the tenant storage write\npermission. Pattern mirrors the recruitment offer public render\n(`5140d334`) and roadmap attachment (`30dbb0bd`).\n\nKey shape shift (forward-only, no migration):\n- Legacy: `<orgId>/support/<attachmentId>/<filename>` via\n  `objectKey({ purpose: 'support' })`\n- New: `<orgId>/support_attachment/<objectId>/<filename>` via\n  the storage module's `buildStorageKey` for\n  `purpose='support_attachment'`\n\nOld uploads keep working because their key shape is persisted\nin `support_ticket_messages.attachments[].key` JSONB — the\ndownload action presigns whatever key is in the row, so old\nand new attachments serve side-by-side without a backfill.\n\nWhat new uploads gain:\n- a real `storage_objects` row (replaces the orphan-blob shape)\n- the Phase G.1.1 confirm-upload HEAD-size enforcement\n- meter event + sharded counter delta for tenant quota\n- per-purpose breakdown surfaces via `purpose='support_attachment'`\n  on the operator dashboard\n- eligibility for the orphan-sweep cron when the ticket /\n  attachment is purged\n\nThe download action keeps its direct `presignDownload` call\nfor now — Phase 2 cleanup migrates downloads to\n`storage.object.get_url` after the legacy attachments are\nbackfilled into `storage_objects` rows (the legacy refs have\nno `objectId` for `get_url` to consume).\n\nModule-specific pre-checks (content-type allowlist + 25 MiB\ncap) stay at the support action layer — `PutUrlInput` is\nintentionally more permissive.\n\nAll 363 support tests pass; zero typecheck errors.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T20:27:41.400Z","updatedAt":"2026-06-19T20:27:41.400Z"},{"id":"114a6563-fdf6-453f-a5b3-9ed57f73fc10","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-attachment-upload-storage-bridge","type":"changed","scope":"chat","summary":"Chat attachment upload now routes through storage.object.put_url instead of direct driver presign.","body":"Phase 6.1.2 of the unified Storage + Drive plan — fourth and\nfinal browser-presign consumer migration in Group 6.1, after\nroadmap (`30dbb0bd`), support (`ce85de48`), and clients\n(`a21cebce`).\n\n`chat.attachment.create_upload_url` now calls\n`getAction('storage.object.put_url')` + `invoke()` via a\n`createSystemContext` with `storage:object:write:own`. The\nsysCtx covers every chat actor (employees, clients,\ncontractors) — chat is open to every authenticated session\nregardless of role blueprint, so the tenant storage write\npermission was the wrong gate to require directly.\n\nKey shape shift (forward-only, no migration):\n- Legacy: `<orgId>/chat/<attachmentId>/<filename>` via\n  `objectKey({ purpose: 'chat' })`\n- New: `<orgId>/chat_attachment/<objectId>/<filename>` via\n  `buildStorageKey({ purpose: 'chat_attachment' })`\n\nOld uploads keep working because `chat_messages.attachments[].key`\nJSONB carries the key shape per-attachment — the download\naction presigns whatever is in the row, so old and new\nattachments serve side-by-side.\n\nWhat new uploads gain:\n- a real `storage_objects` row (no more orphan blobs)\n- the Phase G.1.1 confirm_upload HEAD-size enforcement when\n  chat wires its confirm path (Phase 2 cleanup)\n- meter event + sharded counter delta for tenant quota\n- per-purpose breakdown via `purpose='chat_attachment'`\n- eligibility for the Phase R orphan-sweep cron\n\nThe dependency-unavailable hint that previously surfaced \"Start\nMinIO with docker compose up -d minio\" is preserved — when\nstorage.object.put_url returns `dependency_failed`, the chat\naction appends the same local-dev hint to the surfaced message.\n\nModule-specific pre-checks (content-type allowlist + 25 MiB\ncap) stay at the chat action layer. `isAllowedContentType` +\n`MAX_UPLOAD_BYTES` imported from `@helios/storage` as plain\nvalidation constants (no driver code).\n\nThe download path (`chat.attachment.get_download_url`) keeps\nits direct `presignDownload` — Phase 2 cleanup migrates it to\n`storage.object.get_url` after the legacy refs are backfilled\ninto `storage_objects` rows.\n\nAll 267 chat tests pass; the 3 typecheck warnings in\n`unread.test.ts` are pre-existing (last touched in `b76d7f27`,\nunrelated to this migration).\n\nCloses 6.1.2 from `12_REMAINING_PLAN.md` — **Group 6.1 is now\ncomplete** (clients + chat + support + roadmap all migrated).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T22:37:58.119Z","updatedAt":"2026-06-19T22:37:58.119Z"},{"id":"dfd9cc07-98ff-4dfc-9b18-373fdb2e00c9","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"clients-documents-storage-bridge","type":"changed","scope":"clients","summary":"Client document uploads (operator + portal) now route through storage.object.put_url + confirm_upload.","body":"Phase 6.1.1 of the unified Storage + Drive plan — third browser-\npresign consumer migration after roadmap (`30dbb0bd`) and support\n(`ce85de48`). Touches both the operator-facing\n`clients.document.{create_upload_url, confirm_upload}` and the\nportal-facing `clients.portal.document_{upload, confirm_upload}`.\n\n`create_upload_url` (both surfaces) now calls\n`getAction('storage.object.put_url')` + `invoke()` via a\n`createSystemContext` with `storage:object:write:own`. The sysCtx\ncovers the customer-portal case where the actor is a `client` role\nthat doesn't carry the tenant storage write permission. The\nstorage module's returned `objectId` is used directly as\n`clientDocuments.id` so the 1:1 link to the storage row is\nexplicit (no FK column rename needed; the existing UUID PK already\nmatches `storage_objects.id` shape).\n\n`confirm_upload` (both surfaces) ALSO bridges to\n`storage.object.confirm_upload` via the same sysCtx pattern. That\nfires the Phase G.1.1 HEAD verification (provider-reported size\nmust equal declared size, else row gets quarantined as\n`scan_error`), writes the meter event + sharded counter delta,\nand flips the row's `scanState` to `clean`. Best-effort: legacy\ndocuments pre-Phase-6 have no `storage_objects` row, so the\nstorage confirm returns `not_found` and the action proceeds to\nstamp `uploadedAt` regardless — operator-visible state stays\naccurate.\n\nKey shape shift (forward-only, no migration):\n\n  Legacy: `<orgId>/client-document/<id>/<filename>`\n          via `objectKey({ purpose: 'client-document' })`\n  New:    `<orgId>/client_document/<objectId>/<filename>`\n          via `buildStorageKey({ purpose: 'client_document' })`\n\nThe canonical purpose enum uses underscores; the legacy\n`'client-document'` constant stays in scope as the download-path\nprefix matcher so old documents keep serving from\n`get_download_url`. `support_ticket_messages.attachments[].key`\nJSONB carries the original shape per document, so old + new\nattachments serve side-by-side without a backfill.\n\nWhat new uploads gain:\n- real `storage_objects` row (no more orphan blobs)\n- the G.1.1 confirm_upload HEAD-size enforcement\n- meter event + sharded counter delta for tenant quota\n- per-purpose breakdown via `purpose='client_document'`\n- eligibility for the orphan-sweep cron when the document is\n  soft-deleted or the company / engagement purges\n\nDownload paths (`get_download_url`, `portal.document_download_url`)\nkeep direct `presignDownload` for now — Phase 2 cleanup migrates\nthem to `storage.object.get_url` after the legacy refs are\nbackfilled into `storage_objects` rows.\n\nModule-specific pre-checks (content-type allowlist + 25 MiB cap)\nstay at the clients action layer. `isAllowedContentType` +\n`MAX_UPLOAD_BYTES` imported from `@helios/storage` as plain\nvalidation constants (no driver code). `objectKey` import removed\nfrom both files — its callers all now route through the storage\naction.\n\nAll 202 clients tests pass; zero typecheck errors.\n\nCloses 6.1.1 from `12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T22:37:58.395Z","updatedAt":"2026-06-19T22:37:58.395Z"},{"id":"901dd4c9-4e03-4b6a-828e-09cb80e3041f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-document-email-storage-object-id","type":"fixed","scope":"hrm","summary":"HRM document emails (contract / NDA / joining-letter) now attach PDFs via storageObjectId (closes silent drop bug).","body":"Phase 6.4.4 + the HRM half of 6.4.5 of the unified Storage +\nDrive plan. Closes the second half of the silent\ndrop-attachment bug surfaced by the audit. Together with the\nrecruitment fix (commit `83484dca`), every action that emails\na render-and-cache PDF now attaches the bytes correctly.\n\nThe bug: HRM emails for `contract.sent` / `nda.sent` /\n`joining_letter.sent` / `*_signed` events were arriving in\nmanager + employee inboxes with no PDF attached. Root cause —\nthe subscriber at `modules/hrm/src/jobs/email-on-hrm-events.ts`\npassed `r.storageUrl` (a raw key string) into\n`email.outbound.send`'s `attachments[].storageKey` field, which\nthe drain has silently dropped since commit `b136443a`\n(`email_outbound_attachments` join + `storage.get_stream` made\n`storageObjectId` the authoritative path).\n\nFix shipped in two parts in this single commit:\n\n**6.4.4 schema column** — `hrm_employee_documents.storage_object_id`\nnullable UUID FK → `storage_objects.id` (ON DELETE SET NULL),\nindexed for the drain JOIN + the reverse\n`storage.object.deleted` housekeeping lookup. Mirrors the\n6.4.3 recruitment column shape from commit `88cd0757`.\n\n**6.4.5 propagation** — the three actions that render HRM\ndocuments now capture the `storage_objects.id` returned by\n`cacheHrmDocumentPdf` (already a `Promise<string | null>`)\ninstead of discarding it via fire-and-forget, and persist it\nto `employeeDocuments.storageObjectId` in the same UPDATE that\npins `storageUrl`:\n- `modules/hrm/src/actions/draft-joining-pack.ts`\n- `modules/hrm/src/actions/send-joining-pack.ts` (preserves\n  the existing FK on the promoted-draft path; sets a fresh\n  value on the fresh-render path)\n- `modules/hrm/src/actions/document-revise.ts`\n\nThe email subscriber's two attachment collectors\n(`collectActiveDocAttachments` for joining-pack sends +\n`makeSignedDocAttachments` for the per-event \"signed\" emails)\nnow select `storageObjectId` alongside `storageUrl` and pass\nBOTH to `email.outbound.send`. The drain prefers\n`storageObjectId` (resolves via `email_outbound_attachments`\njoin → `storage.get_stream`); the legacy `storageKey` path\nfalls back only for documents that pre-date the Phase 6 cache\nbridge (which still drop in v1).\n\nFor new HRM document sends going forward, the candidate's\ncontract / NDA / joining-letter PDF actually arrives in their\ninbox. Manager \"candidate signed X\" notifications now carry\nthe executed copy as an attachment too.\n\nLegacy documents (rendered before the Phase 6 cache bridge\nrolled out) still have NULL `storage_object_id`. A backfill\njob that walks legacy `employee_documents` rows + constructs\nstorage rows from the existing `storageUrl` bytes lands\nseparately as part of Phase 2 cleanup.\n\nAll 393 HRM tests pass + 19 db tests confirm the migration\napplies clean. Pre-existing implicit-any typecheck noise in\n`time-manual.test.ts` / `time-reopen.test.ts` /\n`bind-onboarding-tasks.test.ts` is unrelated to this fix.\n\nCloses 6.4.4 + the HRM half of 6.4.5 from `12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T05:34:45.622Z","updatedAt":"2026-06-20T05:34:45.622Z"},{"id":"1a1c3a85-8d2f-4dad-be23-fd3168631cb4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-document-reads-storage-get-url","type":"changed","scope":"hrm","summary":"HRM document read paths prefer storage.object.get_url; legacy direct presign is now the fallback.","body":"Phase 6.2.3 of the unified Storage + Drive plan. Unblocked by\nthe Phase 6.4.4 `storage_object_id` column on\n`hrm_employee_documents` (commit `f418928e`).\n\n`modules/hrm/src/actions/my-document-download.ts` and\n`document-public.ts` now select `storageObjectId` alongside\n`storageUrl`. When the FK is populated (set by the three\ndocument-render actions in `f418928e`), they call\n`storage.object.get_url` via `createSystemContext` instead of\nthe direct provider presign. Falls back to the legacy direct\n`presignDownload` for documents pre-dating the 6.4.4 column.\n\nWhat the migrated read path gains:\n- The per-purpose **access registry hook** fires — for HRM\n  documents the registered verifier checks employee-scope +\n  HR-role + visibility — extra defense in depth on top of the\n  org+employee gate the action already does.\n- The **egress meter event** lands on every download, feeding\n  the per-tenant egress accounting that the operator dashboard\n  surfaces on `/saas/storage`.\n- `scanState !== 'clean'` is enforced — once Phase 8's AV\n  pipeline lands, scanning rejections automatically block\n  downloads with no per-action wiring.\n\nPublic-token actor in `document-public.ts` uses the same\nsysCtx pattern: the token verification above the read already\nestablishes the access right; the sysCtx bridges into\nstorage's permission model with `storage:object:read:own`.\n\nLegacy documents (rendered before the cache bridge populated\nthe FK) keep working verbatim via the existing direct\npresignDownload path — no backfill needed at apply time.\nA backfill walk that retroactively writes\n`hrm_employee_documents.storage_object_id` for legacy rows is\ntracked separately as part of Phase 2 cleanup.\n\nAll 393 HRM tests pass; zero typecheck errors.\n\nCloses 6.2.3 from `12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T05:34:45.627Z","updatedAt":"2026-06-20T05:34:45.627Z"},{"id":"512b597b-4aba-47b7-8fc3-383dbdb6460f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-send-storage-object-id","type":"added","scope":"email","summary":"email.outbound.send accepts storageObjectId attachments and writes structured email_outbound_attachments join rows alongside the legacy jsonb.","body":"Phase 1 commit 8 of the email + mailbox → unified storage migration.\nWires the producing-side of the email outbound attachment migration.\n\n## What changed\n\nThe `attachments` array in `email.outbound.send` now accepts three\nshapes per entry, in preference order:\n\n1. **`storageObjectId`** — canonical. The caller has already\n   called `storage.object.put` (e.g. via the sales PDF cache\n   pattern) and holds the resulting object id. The send action\n   just inserts an `email_outbound_attachments` join row pointing\n   at it.\n\n2. **`contentBase64`** — legacy inline. The send action\n   internally calls `storage.object.put` with `purpose:\n   'email_outbound'`, `ownerKind: 'module'`, `ownerModule: 'email'`,\n   `idempotencyKey: email.outbound.<key>.attachment.<idx>`. The\n   bytes count toward the org's storage quota (correct — the org\n   sent the email). The materialised entry replaces the inline\n   base64 in the persisted `attachments_json`.\n\n3. **`storageKey`** — legacy. Drain skips these as before;\n   accepted for back-compat. Producers SHOULD migrate to (1).\n\nNew optional per-entry fields surface in the send schema:\n\n- `contentId` — RFC 2392 (inline images in the HTML body)\n- `disposition` — RFC 2183: `'attachment' | 'inline'` (default\n  `attachment`)\n- `displayFilename` — recipient-visible name when the underlying\n  `storage_objects.filename` differs\n\n## How the dual-read window works\n\nEvery send writes BOTH:\n\n- The structured rows in `email_outbound_attachments` (one per\n  attachment that resolved to a `storageObjectId`).\n- The legacy jsonb on `email_messages_outbound.attachments_json`\n  (with the materialised form — i.e. inline base64 dropped after\n  materialisation).\n\nPhase 1 commit 9 swaps the drain over to reading the join rows\nfirst, falling back to jsonb for legacy unmigrated outbound rows.\n\n## Producer-side ergonomics\n\nThe action layer is best-effort across two failure modes:\n\n- **Storage action not loaded** (lightweight scripts / tests\n  without the storage module registered). Materialisation\n  silently falls back to legacy carry-through; the legacy drain\n  path keeps working.\n- **`storage.object.put` errored** (provider down, quota\n  exceeded, etc.). Logged with structured context; legacy carry-\n  through. The producer's existing retry semantics remain\n  unchanged.\n\n## Tests\n\n`modules/storage/src/lib/email-send-attachment-shape.integration.test.ts`\n(5 PGlite cases through the real `email.outbound.send` action):\n\n- storageObjectId attachment → exactly one join row, ordering 0\n- contentBase64 attachment → materialised via storage.object.put,\n  jsonb has objectId set and base64 stripped\n- inline + attachment shapes coexist; Content-ID partial unique\n  survives\n- storageKey-only carries through jsonb only, no join row\n- idempotent re-send returns existing outbound row + does NOT\n  double-write join rows\n\n98 storage unit + 68 integration tests pass; 270 email module\ntests unchanged.\n\n## Reference\n\n- Schema: [modules/email/src/schemas/send.ts](../../modules/email/src/schemas/send.ts) (`attachments` array)\n- Action: [modules/email/src/actions/send.ts](../../modules/email/src/actions/send.ts) (`materialiseAttachments`, `writeOutboundAttachmentJoinRows`)\n- Table: `email_outbound_attachments` (Phase 1 commit 7,\n  `88bcb82d4160961c6fbd1d9566a1ce9d0e594cec`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:02.302Z","updatedAt":"2026-06-16T13:46:02.302Z"},{"id":"fe8cd415-78ed-4724-8155-2d06f0d3e046","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-feed-legacy-key-cleanup","type":"removed","scope":"web","summary":"Legacy ['iam.user.onboarding.get','self'] query-key invalidations dropped from cross-tab broadcast + same-tab handlers (Phase 5.D/5).","body":"Phase 5.D/5 — the last piece of the Phase 5 cleanup. The legacy\n`['iam.user.onboarding.get','self']` query key is gone:\n\n**auth-broadcast.ts:** dropped from the `two-factor`, `passkeys`,\n`profile`, and `onboarding` invalidation lists.\n\n**Same-tab invalidations:** dropped from\n- `settings/security.tsx` (4 mutation handlers — passkey enroll,\n  passkey revoke, 2FA verifyEnroll, 2FA disable)\n- `settings/profile.tsx` (profile-form submit)\n- `verify-email.tsx` (status=success effect)\n- `onboarding.tsx` (complete + skip mutations)\n\nEach handler now invalidates only the canonical\n`['platform.onboarding.feed.list','self']` (security.tsx and\nprofile.tsx already had it; the four extra lines were\nback-compat noise).\n\n**Docstrings:** `verify-email-banner.tsx` and `onboarding.tsx`\nand `setup.tsx` get their action-name references updated so\nfuture readers don't grep for the dead legacy action.\n\nThis is the final Phase 5 commit. The unified onboarding feed\nend-to-end:\n- ✅ Read action: `platform.onboarding.feed.list`\n- ✅ Write dispatchers: `platform.onboarding.feed.{complete,skip}_step`\n- ✅ Owner-module registrations: iam (user kind) + saas (org kind)\n- ✅ UI consumers: wizard, banner, dashboard widget, score card,\n     root-route gate, org-setup banner, /setup wizard\n- ✅ Cross-tab + same-tab invalidations point at the canonical key\n- ✅ Legacy READ actions retired (Phase 5.D/4)\n- ✅ Legacy query keys cleaned (Phase 5.D/5)\n\nThe legacy WRITE actions (`iam.user.onboarding.complete_step` /\n`skip_step` and `saas.organization.setup.complete_step` /\n`skip_step`) stay registered — the dispatcher forwards through\nthem so they keep firing events + writing audit + persisting\nstate. Future deletion is possible once an in-action variant\nof the dispatcher's logic exists.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.810Z","updatedAt":"2026-06-15T22:14:00.810Z"},{"id":"9178c9f6-275f-46e4-a861-f5ec77b08f9b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-strictness-phase-g-ui-fixes","type":"fixed","scope":"web","summary":"Strictness Phase G UI fixes — score card surfaces warning-level recommendations + wizard hides them + 6 resolver regression tests.","body":"Completes the adversarial-review hardening. Blocker 4 + Major 6\nland here along with the regression-test suite the review's\n\"next-session action #3\" called for.\n\n**Blocker 4 — `warning`-level items had no UI surface.** Before\nthis commit, an admin could mark `user:set_locale_preferences` as\n`warning` (the catalog default) and the entry showed up nowhere:\nsuppressed from the dashboard widget (Phase F), suppressed from the\nchrome banner (Phase F), absent from the security score card. Power\nusers had no way to even know an advisory step existed.\n\n`SecurityScoreCard` now consumes `platform.onboarding.feed.list`\nalongside its existing 5 hardcoded checks and renders a\n\"Recommendations\" sub-section showing every `hideFromBanners=true &&\n!completedAt` feed item. The recommendations are advisory — they\ndon't affect the score percentage per spec D-4 — but they're\nlinkable + visible. The card now renders when EITHER there's a\nscore gap OR there are pending recommendations (instead of hiding\nonly when score=100%).\n\n**Major 6 — `/onboarding` wizard didn't filter `hideFromBanners`.**\nThe wizard pulled `s.items.filter((i) => i.kind === 'user')` — including\nwarning-level items — but the progress label read from\n`feed.progress.total` which already excludes them. Result: \"2 of 4\ndone\" beside 5 rendered rows for any user with a warning step.\nNow: `i.kind === 'user' && !i.hideFromBanners`.\n\n**Regression suite (6 new tests in\n`modules/iam/src/onboarding/register-feed.test.ts`).** Pins the\nPhase G fixes against future regressions:\n- Blocker 1: platform override IS consulted with prefixed key\n  (`user:verify_email`) — pre-fix this returned `strict` instead\n  of `off`.\n- Blocker 1 (org side): org override IS consulted with prefixed key\n  (`user:enable_two_factor`).\n- Blocker 2: stricter-only clamp — org `off` + platform `strict`\n  → effective `strict` (not `off`).\n- Blocker 3: `user:accept_terms` short-circuits to `strict`\n  regardless of platform OR org overrides.\n- Legacy `requireUserOnboarding=false` downgrade defers to explicit\n  prefixed pin.\n- Legacy `requireEmailVerification=false` downgrade defers to\n  explicit prefixed pin.\n\nTest posture: 121 iam (was 115, +6 regression) + 123 platform + 411\nsaas = 655 green.\n\nWhat remains for next session (not blockers):\n- R1 #5 — permission semantic cosmetic (covered by existing\n  `iam:organization:update` but a dedicated key would be cleaner).\n- R1 #6 — atomic jsonb merge via `COALESCE(metadata,'{}') ||\n  $1::jsonb` instead of the read-modify-write loop.\n- R3 #4 — replace the raw `<select>` in the platform + org UIs with\n  the shared `@helios/ui` `Select` primitive.\n- Spec D-3 UI lock — the dropdown for `user:accept_terms` should be\n  disabled in both UIs (the schemas reject the write but the dropdown\n  still lets the user pick the value).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.051Z","updatedAt":"2026-06-16T13:46:03.051Z"},{"id":"7113f341-a34e-4c23-a1e4-cdfcf51d4650","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-pdf-storage-cache","type":"changed","scope":"hrm","summary":"HRM document PDFs (joining letters, contracts, NDAs) now count against the org's plan-tier storage quota.","body":"Phase 6 of the unified Storage + Drive plan — HRM as the second\nconsumer to migrate onto the Storage actions (after sales). The\nlegacy direct `getStorage().putObject()` write inside\n`renderHrmDocumentPdf` is unchanged; this lands a fire-and-forget\nbridge that ALSO writes the bytes through `storage.object.put` so\nthey:\n\n- Count against the org's plan-tiered storage quota (Storage\n  module's per-purpose + total caps now apply to HRM docs).\n- Surface in the per-purpose metering pie chart on\n  `/saas/storage`.\n- Power the future `/drive/System/HR/...` virtual folder once\n  Phase 10 lands.\n\n`cacheHrmDocumentPdf(ctx, { kind, employeeId, documentId, version,\nemployeeNumber, bytes })` in `@helios/hrm/src/lib/document-storage-\ncache.ts` mirrors the sales bridge. Idempotency key\n`hrm.<kind>.<documentId>.v<version>` dedups the meter event on\nre-render. Sha256 content-addressing (migration 0277_0278) collapses\nidentical bytes within an org to a single row + refcount bump, so\nre-rendering an unchanged PDF doesn't write a second copy.\n\nWired into three admin-context call sites:\n- `modules/hrm/src/actions/send-joining-pack.ts`\n- `modules/hrm/src/actions/draft-joining-pack.ts`\n- `modules/hrm/src/actions/document-revise.ts`\n\nThe public-sign action (`document-public-sign.ts`) is intentionally\nskipped — the public-token actor doesn't carry the\n`storage:object:write:own` permission. Its legacy direct upload\ncontinues unchanged; the candidate's signed PDF lands in S3 via the\nunmetered path until the system-context bridge for public actors is\nin place (separate work).\n\nThe cache write is fire-and-forget: a Storage-module write failure\ndoes NOT break the PDF download or the legacy upload. The\ngetAction() guard keeps the HRM runtime loadable when\n`@helios/storage-module` isn't wired (unit tests, lightweight\nscripts).\n\n3 PGlite integration tests in `modules/hrm/src/lib/document-storage-\ncache.integration.test.ts`:\n- happy path: row + meter event + sharded counter all land\n- dedup: re-rendering the same document version refcount-bumps\n  (sha256 dedup), single meter event, single counter delta\n- policy-deny: actor without `storage:object:write:own` returns\n  null without throwing or writing rows\n\n`@helios/storage-module` added as a devDependency on `@helios/hrm`\nbecause the integration test imports it for the registerAction\nside effects. Production HRM runtime stays clean — only the test\nwires the module.\n\nSee `docs/plans/UNIFIED_STORAGE_AND_DRIVE/08_MODULE_INTEGRATION_GUIDE.md`\n§hrm for the full Drive-tier-A surface plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.281Z","updatedAt":"2026-06-16T13:46:03.281Z"},{"id":"2774cc49-4531-4adc-b5ba-ffa8c890ab3c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-vote-tier-weights","type":"added","scope":"roadmap","summary":"Per-plan vote weight multipliers (Phase 8b) — paid tiers can carry more weight in roadmap demand.","body":"Roadmap settings now expose a per-plan vote weight map. Operators assign a multiplier per `saas_plans.slug` (with a `*` wildcard fallback); votes from members of those plans contribute that weight to the new `weightedVoteCount` denorm. Raw voter counts remain visible on every surface — admin sheets show both \"12 voters\" and \"19 demand\" when they diverge. Existing votes pre-Phase-8b are unchanged (every legacy vote still counts as 1).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:29.443Z","updatedAt":"2026-06-15T17:29:29.443Z"},{"id":"869f8f78-452f-47f0-aa53-29d4086ce3ff","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-attachment-download","type":"added","scope":"mailbox","summary":"Click a paperclip in /mail to download the attachment — proxy endpoint streams provider bytes back to the authenticated user.","body":"M5-b3 slice 1 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. The\nreader showed attachment metadata via the existing\n`mailbox.message.get` action but clicking did nothing —\nattachments were undownloadable.\n\n## Endpoint — `apps/web/src/server/mailbox-attachment.ts`\n\n`GET /api/mailbox/attachment/<messageId>/<providerAttachmentId>`\n\nAuthenticated. Resolves the message + the owning account, runs\nthe same ownership and impersonation checks as\n`mailbox.message.get`:\n\n- 401 if not signed in\n- 404 if message or account missing / soft-deleted\n- 403 if shared-scope (routes through future `/api/mailbox/shared/attachment/…`)\n- 403 if actor doesn't own the account\n- 403 if session is an impersonation AND the account is personal\n  (Q15 — the personal-scope privacy invariant is absolute)\n- 404 if the attachment id isn't on the message's\n  `attachments_json`\n\nThen calls `provider.fetchAttachment(binding, ref)` — Gmail\n`attachments.get` / Graph\n`/me/messages/<id>/attachments/<id>/$value` — and streams the\nbytes back with:\n\n```\ncontent-type: <provider-reported>\ncontent-disposition: attachment; filename=\"<filename>\"\ncontent-length: <bytes>\ncache-control: private, no-store\n```\n\nProvider failure → 502.\n\n## Implementation notes\n\n- **No object-storage mirroring.** The bytes don't sit in our\n  bucket; we proxy-stream on each request. Pros: zero storage\n  cost, no backfill, works for every reachable attachment.\n  Cons: doesn't scale to multi-GB attachments + every download\n  hits the provider's quota. Both acceptable for v1; the\n  mirroring path lands with the upcoming Drive/Storage module.\n- **RFC 5987 filename encoding.** Non-ASCII filenames (CJK,\n  accents) are percent-encoded into the `Content-Disposition`\n  header so browsers render the right name.\n- `HEAD` requests return the same headers with an empty body —\n  useful for pre-flight size checks.\n\n## UI in `/mail`\n\nThe reader pane's `MessageBody` now renders an `AttachmentStrip`\nbelow the body when the message has non-inline attachments:\n\n- Each attachment shows as a pill: paperclip icon, filename,\n  size (auto-formatted B / KB / MB / GB).\n- Pill is an `<a href>` with the right `download` attribute so\n  the browser triggers a real save dialog.\n- Inline attachments (those with a `cid:` reference rendered\n  inside the HTML body via iframe) are filtered out — they\n  already appear in the body.\n\n## Module wiring\n\n- `modules/mailbox/src/lib/index.ts` exports `bindingFromRow` +\n  `persistRefreshedTokens` from a new public barrel.\n- `modules/mailbox/package.json` adds `\"./lib\"` to exports so\n  consumers (this proxy, future modules) can import the public\n  helpers.\n- `apps/web/package.json` adds `@helios/mailbox-sync` so the\n  attachment endpoint can use the provider runtime + types.\n- `apps/web/src/server/prod.ts` routes\n  `/api/mailbox/attachment/*` to the new handler.\n\n## Net behavior\n\nA user with a Gmail or Microsoft 365 mailbox opens a thread\nin `/mail`, expands the latest message, sees the attachment\nstrip below the body, clicks → file downloads with the\ncorrect filename + type. Works in the background-tab too;\nthe right cache headers prevent stale browser caches from\nshowing yesterday's attachment.\n\nNext: M5-b3 slice 2 — folder sidebar\n(INBOX/SENT/DRAFTS/TRASH/STARRED system folders + thread-list\nfilter). Then slice 3 — Cmd+K command palette.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.755Z","updatedAt":"2026-06-15T19:59:57.755Z"},{"id":"0bca3622-10cd-42da-b4a8-be5849942ac6","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"other-signin-methods-disclosure","type":"changed","scope":"web","summary":"'Other sign-in methods' on /login (magic link, email OTP, LDAP) collapses behind a <details> disclosure so the form keeps a confident shape when those methods aren't used.","body":"The \"Other sign-in methods\" section at the bottom of /login —\nPasswordlessSignIn (magic link + email OTP) plus LdapSignIn —\nwas always visible when the relevant providers were configured.\nWith magic-link + OTP + LDAP all on, that adds ~3 extra blocks\nbelow the password form for users who'd rather just type their\npassword.\n\nWrapping the section in a native `<details>` with the existing\n\"Other sign-in methods\" caption as the `<summary>` defaults it\nclosed. One click reveals the methods; the hero card stays\nfocused on passkey + OAuth + email/password — the common case.\n\nThe SaaS-config-driven visibility logic\n(`magicLinkEnabled || emailOtpEnabled || ldapEnabled`) is\nunchanged; the section only renders when at least one of the\nmethods is configured. The disclosure is purely visual.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.987Z","updatedAt":"2026-06-15T19:59:57.987Z"},{"id":"30855534-15f5-452d-b250-73239e6f6672","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-filename-sanitiser-hardening","type":"changed","scope":"storage","summary":"safeFilename strips leading dots; rich safeFilenameInfo variant preserves the original name in storage_objects.metadata_json.original_filename.","body":"Phase 1 commit 4 of the email + mailbox → unified storage migration.\nSmall but important safety hardening on the filename sanitiser that\nevery producer leans on via `storage.object.put` / `put_url`.\n\n## What changed\n\n- **Leading dots are stripped.** Previously `..pdf` or `.gitignore`\n  passed through as-is. Some viewers hide leading-dot files on\n  download (Unix convention for hidden files), so a hostile filename\n  could become invisible. Now `.gitignore` → `gitignore`,\n  `..hello.pdf` → `hello.pdf`, `....` → `file` (the fallback\n  generic when the strip eats everything).\n\n- **New `safeFilenameInfo` variant.** Returns\n  `{ safe, original, didChange }` so producers can preserve the\n  user-typed name verbatim. The existing `safeFilename` API stays\n  shape-identical for the 36+ callers that consume it via\n  `buildStorageKey`.\n\n- **`storage.object.put` + `put_url` now stash the original\n  filename in `metadata_json.original_filename`** whenever\n  sanitisation changed the input. Admin debug surfaces (and the\n  future Drive UI's \"what did the user actually type?\" hint) can\n  recover the unsanitised name. Caller-supplied metadata wins on\n  conflict — `original_filename` is the only reserved key. Skipped\n  when sanitisation was a no-op (well-formed filenames are 99% of\n  inserts; keeps the payload small).\n\n## What stayed the same\n\n- The `safeFilename(filename: string): string` API — every caller\n  (`buildStorageKey` callers, per-module helpers in platform / sales\n  / etc.) sees the same shape.\n- Path-traversal flattening behaviour. `/` and `\\\\` were already\n  collapsed to `_`. Internal `..` sequences are kept as literal\n  chars (no traversal possible — storage keys delimit on `/` only,\n  and all of those are gone after sanitisation).\n- Null-byte stripping, control-char stripping, 200-char truncation,\n  all-special-fallback to `'file'`.\n\n## Tests\n\n`modules/storage/src/lib/keys.test.ts` extended:\n- leading-dot strip cases (`.gitignore`, `..hello.pdf`, `....`)\n- null-byte case\n- path-traversal flattening case (asserts the safe output for a\n  documented well-known attack input)\n- new `safeFilenameInfo` cases: round-trip the original verbatim,\n  flag `didChange` correctly across no-op + changed inputs\n\n84/84 storage unit tests (80 prior + 4 new); 53/53 integration tests\nunchanged.\n\n## Reference\n\n- Helper: [modules/storage/src/lib/keys.ts](../../modules/storage/src/lib/keys.ts)\n- Wiring: [modules/storage/src/actions/object.ts](../../modules/storage/src/actions/object.ts) (`put`, `put_url` handlers; new `withOriginalFilename` helper)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:28.981Z","updatedAt":"2026-06-16T16:34:28.981Z"},{"id":"157d1b19-806a-40e1-9131-06321f42a9b0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-customer-ui-polish","type":"fixed","scope":"roadmap","summary":"Roadmap detail + submit surfaces get UX polish — composer state, tab-preserving back link, alert banner, never-drop tags.","body":"A bundle of customer-facing polish from Phase R-4:\n\n- The detail-page comment composer now flips its Post button to \"Posting…\" while the request is in flight, disables the textarea, and tints the character counter amber at 90% / red at the cap.\n- Back-navigation from a feature detail page now restores the same tab (Roadmap kanban vs Browse all board) the visitor was on, by passing the active tab through the URL search param.\n- The \"Currently experiencing issues\" status banner picks up `role=\"alert\"` and a complete `aria-label` so screen-reader users hear the impact and incident state without relying on the visible icon.\n- The submit form's tag input flushes any uncommitted draft on form submit, so users who type a tag and hit the Submit button without pressing Enter never silently lose it.\n\nAudit findings **E2, E3, E5, E6, E7** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:12:13.786Z","updatedAt":"2026-06-15T20:12:13.786Z"},{"id":"63826344-a104-4e52-b41d-a3a6d1595543","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"callaction-non-json-handling","type":"fixed","scope":"web","summary":"`callAction` no longer crashes the UI with \"Unexpected token '<'\" when the server returns HTML — surfaces a structured ActionCallError instead.","body":"The user reported: \"replacing or uploading a new avatar image shows\n`Unexpected token '<', '<!doctype'... is not valid JSON` behind the\nupload input.\"\n\nDiagnosis: `callAction` in `apps/web/src/lib/api.ts:151` called\n`res.json()` unconditionally. When the response was HTML (a 404\ncatchall page, an auth-redirect login page, a reverse proxy's error\npage, or a CSP/CORS failure surfaced as HTML), parsing threw\n`SyntaxError: Unexpected token '<', '<!doctype'...` — propagated\nup the stack and landed verbatim in the FormImagePicker's\n`localError` state. Every other `callAction` caller had the same\ncliff.\n\nFix: inspect the response's `content-type` header before parsing.\nWhen the response is non-JSON, snip the first 200 chars of the\nbody for context and throw a structured `ActionCallError` with the\nright code:\n\n  - HTTP 404 → `not_found`\n  - HTTP 401 / 403 → `policy_denied`\n  - HTTP 5xx → `dependency_failed`\n  - everything else → `validation_failed`\n\nPlus a clear English message: *\"Action endpoint returned a non-JSON\nresponse (HTTP {status} {statusText}). This usually means the route\nisn't registered, an auth gate bounced you to a login page, or a\nreverse proxy returned an error page.\"*\n\nThe FormImagePicker (and every other `callAction` consumer that\ncatches `ActionCallError`) now shows that message inline. The user\ngets a meaningful diagnostic instead of a JSON-parse error.\n\nThis is the client-side polish; the upstream cause of the HTML\nresponse on staging (likely a build artifact missing the action\nregistration, a stale auth session, or a proxy intercept) still\nneeds to be diagnosed live. The user can re-run the upload and the\nnew error message will point at the actual failure mode.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:12.459Z","updatedAt":"2026-06-16T17:53:12.459Z"},{"id":"69f9bc48-5840-4159-8b74-7035decfb3fa","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-inbox-bulk-actions","type":"added","scope":"support","summary":"Agents can select tickets in the inbox list and act on them in bulk.","body":"The support inbox list view now has per-row selection checkboxes. Selecting\none or more tickets reveals a bulk-action bar to set status, set priority,\nassign (or unassign) an agent, archive, or mark as spam across the\nselection in one call — surfacing the previously UI-less\n`support.ticket.bulk_action`. The selection clears when the filter or view\nchanges so stale ids can't be acted on.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.274Z","updatedAt":"2026-06-15T22:14:01.274Z"},{"id":"9b2e1091-85f4-4f38-a6d4-1d007fa09c9d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"totp-challenge-extracted","type":"changed","scope":"web","summary":"TotpChallenge moved out of login.tsx (down from 1394 → 1057 lines) into its own component so the route reads as auth orchestration.","body":"Pure code organisation. The TOTP challenge UI (authenticator /\nemail-OTP / backup-code segmented control, OtpInput cells,\ntrust-device checkbox, error rendering) lived inline in\n`login.tsx`, weighing the route at 1394 lines. The route's\nactual job is auth orchestration (signIn → totpRedirect →\napplyAuthChange / cancel); the challenge state machine is\nperipheral.\n\nMoved verbatim to `apps/web/src/components/totp-challenge.tsx`\n(364 lines). `login.tsx` drops to 1057 lines. No behaviour\nchange — same props (`onSuccess`, `onCancel`), same render\noutput, same `authClient.twoFactor.{verifyTotp,verifyBackupCode,\nverifyOtp,sendOtp}` calls.\n\nCleaned up the now-unused imports (`Checkbox`, `Input`, `Key`,\n`Warning`, `OtpInput`) from the login route.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:12:13.788Z","updatedAt":"2026-06-15T20:12:13.788Z"},{"id":"f2ce1a9a-b429-48cc-99cb-5a746048428f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-folder-sidebar","type":"added","scope":"mailbox","summary":"Folder sidebar in /mail — Inbox / Starred / Archive / Trash / All mail filters nested under each account.","body":"M5-b3 slice 2 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3.\nPreviously the mailbox showed exactly one view per account\n(the inbox). Now the user can switch between five folders\nwith one click — restoring the standard mail-client mental\nmodel without sacrificing the polished three-pane layout.\n\n## Folders shipped\n\n- **Inbox** (default): active threads, not archived, not\n  trashed. The \"what needs my attention\" view.\n- **Starred**: threads where `has_starred = true`. Persists\n  across archive but not trash.\n- **Archive**: threads with `archived_at IS NOT NULL`.\n  Mail that's out of the inbox but still searchable.\n- **Trash**: soft-deleted threads (`deleted_at IS NOT NULL`).\n  Provider-side deletes land here via the incremental sync's\n  `deletedProviderMessageIds`.\n- **All mail**: everything except trash.\n\nSent + Drafts are deferred — they need provider-label storage\n(M4-next) so the action can identify which threads contain\nsent/draft messages without a join-heavy subquery.\n\n## Action — `mailbox.thread.list`\n\n`ThreadListInput` gains a `folder: ThreadFolder` field\n(defaults to `'inbox'`). The handler maps each folder to its\nWHERE clause:\n\n```\ninbox    → deleted_at IS NULL AND archived_at IS NULL\nstarred  → deleted_at IS NULL AND has_starred = true\narchive  → deleted_at IS NULL AND archived_at IS NOT NULL\ntrash    → deleted_at IS NOT NULL\nall      → deleted_at IS NULL\n```\n\nCursor pagination, snippet preview, `unreadOnly` filter all\ncontinue to work as before.\n\n## UI — `apps/web/src/routes/mail.tsx`\n\nThe 220px account sidebar now renders a `FolderTree` under\neach active account: indented 12px, icon + label per folder,\nthe active folder gets the same `bg-active` pill the active\naccount row uses. Click to switch; the thread list refetches\nunder a folder-keyed query cache.\n\nSwitching accounts resets the folder back to `'inbox'` so\nthe user always lands on the \"needs attention\" view first.\n\nThe middle-pane header label updates per folder (\"Archive\"\n/ \"Starred\" / etc.). The empty state pulls per-folder copy +\nicon — \"Trash is empty\" / \"No starred threads\" / etc.\n\n**Archive mutation** now respects the current folder: a\nthread archived from Inbox optimistically leaves the list +\nauto-advances to the next; unarchived from Archive does the\nsame in reverse. In Starred / All / Trash, the thread stays\nvisible after the toggle.\n\n## Test coverage (+4, 169 total mailbox-module tests)\n\n`read.test.ts`:\n- `folder=starred` returns only starred\n- `folder=archive` returns archived + `folder=inbox` hides them\n- `folder=trash` returns soft-deleted + Inbox excludes them\n- `folder=all` returns archived but excludes trash\n\nExisting tests continue to pass because the default\n(`'inbox'`) preserves the prior behavior verbatim.\n\n## Net behavior\n\nThe mailbox now has the standard folder model: one click\nper folder, keyboard shortcuts (`J/K` walk, `E` archive,\n`S` star, `U` read, `C` compose, `R` reply) work\nidentically inside every folder, and switching folders\nshows the right thread set without losing the polished\nthree-pane layout.\n\nNext: M5-b3 slice 3 — Cmd+K command palette (jump to\nthread, switch account, switch folder, quick-action). Then\nthe M-queue moves to M2-f (shared inbox + admin business\nprovisioning), M1g/h (IMAP/JMAP), M10+ (AI panel).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:12:13.790Z","updatedAt":"2026-06-15T20:12:13.790Z"},{"id":"d12a524f-dee7-467e-a6b5-b35a53b2eb46","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-macro-apply-rail","type":"added","scope":"support","summary":"Agents can apply a saved macro to a ticket from the detail sidebar.","body":"The ticket detail sidebar now has an \"Apply a macro\" picker (agent-only).\nChoosing a macro runs its bundled actions — set status / priority /\nassignee, add a reply, and so on — against the current ticket in one click,\nthen refreshes the conversation, audit log, and SLA state. The block hides\nitself when the org has no macros. Surfaces the previously UI-less\n`support.macro.apply` / `support.macro.list`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.273Z","updatedAt":"2026-06-15T22:14:01.273Z"},{"id":"04eaaf6e-4bd8-4dbb-b05f-7384fc2454c4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-requester-company-link","type":"changed","scope":"support","summary":"A ticket's requester company now links to its CRM company record.","body":"In the ticket detail sidebar, the requester's company name is now a link to\nthat company's CRM record (when the ticket is tied to a known company), so\nagents can jump from a ticket to the customer's 360 in one click. Falls back\nto plain text when no company is linked.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.316Z","updatedAt":"2026-06-15T22:14:01.316Z"},{"id":"0cb6d4b6-e0fa-415e-a1e0-18e89a969773","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-imap-timeout-and-errors","type":"fixed","scope":"mailbox","summary":"IMAP connect now fails fast — 12s connect timeout + 20s hard ceiling at the action layer + actionable error messages for ECONNREFUSED / ENOTFOUND / ETIMEDOUT / TLS / auth failures.","body":"A user reported that the new IMAP / SMTP connect form (shipped at\n`df789fd7`) blocked for ~30s before showing a generic \"request timed\nout\" toast when their host was wrong / unreachable. Three things\ncombined to produce that:\n\n- ImapFlow's default `connectionTimeout` is **90 seconds** — far\n  longer than the 30s API gateway tolerates.\n- `mapImapError` mapped every network failure to a generic \"IMAP\n  network error: <raw message>\" string — operationally useless.\n- The action had no upper bound of its own, so a misbehaving server\n  could hang post-greeting past the gateway timeout.\n\n## What changed\n\n- **ImapFlow constructor gains explicit timeouts** in\n  `packages/mailbox-sync/src/providers/imap/provider.ts:openClient`:\n  - `connectionTimeout: 12_000` (was 90s default)\n  - `greetingTimeout: 8_000` (was 16s default)\n  - `socketTimeout: 30_000` (was 5min default — affects post-connect idle)\n\n- **Action-layer hard ceiling at 20 seconds.** The\n  `mailbox.account.connect_imap` handler now races the\n  `testConnection` call against a 20s `Promise` that rejects with a\n  clean `network` ProviderError. Even if imapflow's own timeouts\n  glitch, the action returns before the API gateway times out and\n  the user sees a clear error rather than a generic 30s toast.\n\n- **`mapImapError` upgraded with OS-code-aware messages**:\n  - `AUTHENTICATIONFAILED` / \"auth\" → \"IMAP login rejected. Double-\n    check the username and password (many providers require an\n    app-specific password, not the account's primary password).\"\n  - `ECONNREFUSED` → \"Connection refused. The host is reachable but\n    nothing is listening on the port — verify host + port (IMAPS =\n    993, STARTTLS = 143).\"\n  - `ENOTFOUND` / `EAI_AGAIN` → \"Could not resolve the IMAP host.\n    Check the spelling.\"\n  - `ETIMEDOUT` → \"IMAP connection timed out. The server is\n    unreachable or behind a firewall.\"\n  - TLS / cert mismatch → \"Try toggling the Use TLS switch — port\n    993 expects implicit TLS, port 143 expects STARTTLS.\"\n\n- **Action no longer double-wraps provider error messages** with\n  `\"IMAP probe failed: …\"` — the ProviderError already carries the\n  user-friendly string from `mapImapError`. The wrap survives for\n  non-ProviderError causes (truly unexpected failures).\n\n## Tests\n\n`modules/mailbox/src/actions/connect-imap.test.ts` extended (16\ncases total): new \"caps the IMAP probe at the action-level 20s\nceiling\" case uses vitest fake timers to advance 21s through the\nrace; asserts the action returns `dependency_failed` with the\n\"longer than 20 seconds\" message.\n\n299/299 mailbox-module tests pass.\n\n## Operator-side TODO\n\nThe staging deploy that produced the report also showed\n`/api/actions/mailbox.account.connect` (the Gmail/Microsoft OAuth\nstart action — different code path) returning 500. The\n`mailbox_config` resolver likely throws because either the\n`platform_settings.mailbox_config` jsonb is malformed OR the\n`MAILBOX_OAUTH_STATE_SECRET` fallback derivation throws. That's a\nfollow-up commit — needs server-log access to triage.\n\n## Reference\n\n- ImapProvider: [packages/mailbox-sync/src/providers/imap/provider.ts](../../packages/mailbox-sync/src/providers/imap/provider.ts) (`openClient`, `mapImapError`)\n- Action: [modules/mailbox/src/actions/connect-imap.ts](../../modules/mailbox/src/actions/connect-imap.ts)\n- M1g shipped: `df789fd7`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.452Z","updatedAt":"2026-06-16T13:46:03.452Z"},{"id":"a5efddd0-2449-4b17-8f93-78c809382a6a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-status-dwell-from-audit","type":"fixed","scope":"support","summary":"Automations now measure time-in-status from the last status change, not last edit.","body":"The `ticket.minutesInCurrentStatus` fact used by support triggers, automation\nrules, and SLA policies is now derived from the latest `status_changed` audit\nevent (falling back to ticket creation), instead of the ticket's `updated_at`\ntimestamp. Previously any unrelated field edit — adding a tag, changing\npriority — reset the dwell clock, so a ticket that had sat in \"pending\" for a\nweek read as freshly entered. Time-based escalations and conditions now fire\nwhen they should.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.321Z","updatedAt":"2026-06-15T22:14:01.321Z"},{"id":"915965e8-c91b-402e-ab0b-535f77f16b2d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-public-create-perms","type":"fixed","scope":"support","summary":"Public contact form and widget offline form can file tickets again.","body":"`support.public.submit_contact` and `support.widget.submit_offline_form`\nbuilt their system context with `support:ticket:create:any` — a scoped\npermission that doesn't exist (ticket-create perms are unscoped). The\nticket-create policy requires `support:ticket:create` /\n`:create_for_others`, and the system context applies no scope expansion, so\nboth public-facing forms passed their anti-spam gates and then failed with\n`policy_denied` instead of filing a ticket. Both now grant\n`support:ticket:create` (matching every other system-context caller), so\ncontact-form and widget offline submissions create tickets as intended. A\nnew integration test exercises the full happy path against real Postgres.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:31:32.481Z","updatedAt":"2026-06-15T20:31:32.481Z"},{"id":"75ee765b-ad93-43c6-95c7-8ae3a007b191","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-oauth-resolve-defensive","type":"fixed","scope":"mailbox","summary":"mailbox.account.connect no longer 500s when platform_settings.mailbox_config column is missing — falls back to env vars + logs a warning.","body":"Same staging report that surfaced the IMAP timeout (just-shipped\n`15ffacda`) also showed\n`/api/actions/mailbox.account.connect` (the Gmail / Microsoft\nOAuth-start action — different code path) returning 500. The\nroot cause: `resolveMailboxConfig` did a hard `SELECT` against\n`platform_settings.mailbox_config`. Staging hasn't run migration\n`0274_0275` (the column-add), so Postgres throws \"column does not\nexist\" and the action's handler — which didn't catch generic SQL\nerrors — bubbled it up as a generic 500. Users saw a useless toast\ninstead of the friendly `dependency_failed: \"google OAuth not\nconfigured…\"` message that was meant to fire.\n\n## What changed\n\n- **`resolveMailboxConfig`** wraps the platform_settings SELECT in a\n  try/catch. On any failure (column missing, table missing, transient\n  DB hiccup) it:\n  - logs a `console.warn` line pointing at migration 0274_0275\n  - falls through to the env-var path\n  - returns the same MailboxConfig shape as the happy path\n\n  Net result: deployments that haven't run the migration keep working\n  with env vars; deployments where the env vars are also unset see\n  the action's existing friendly `dependency_failed` error instead\n  of a generic 500.\n\n- **Tighter inline type**. `MailboxConfigRow` declared locally so the\n  type-narrowing on `cfg?.field` works cleanly with the new\n  try/catch flow.\n\n## Tests\n\n`modules/mailbox/src/lib/mailbox-config.test.ts` (new, 2 cases):\n- DB throws (simulates missing migration) → falls back to env, sources\n  marked `'env'`, warning logged with the migration hint.\n- DB throws AND env vars are unset → returns `sourcedFrom: 'none'` so\n  the action's null-check kicks in and surfaces\n  `dependency_failed`.\n\n22/22 existing mailbox connect tests still pass.\n\n## Operational note\n\nThis is a **fail-soft** fix — the right answer is still to run\n`pnpm db:migrate` on the staging deploy so the column exists and\nthe platform admin can paste OAuth credentials at `/saas/mailbox`\nwithout needing env vars. The defensive read just makes the system\ndegrade gracefully rather than crashing.\n\n## Reference\n\n- Resolver: [modules/mailbox/src/lib/mailbox-config.ts](../../modules/mailbox/src/lib/mailbox-config.ts)\n- Migration that's missing on staging: `0274_0275_platform_mailbox_config.sql`\n- The other half of this report (IMAP probe timeout) was fixed at `15ffacda`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.467Z","updatedAt":"2026-06-16T13:46:03.467Z"},{"id":"2ec0f96e-17fe-4da7-be28-35092f51a05b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-ticket-merge-ui","type":"added","scope":"support","summary":"Agents can merge a duplicate ticket into another from the detail view.","body":"The ticket detail header now has a \"Merge\" action (agent-only). It opens a\npanel to search the inbox by subject, pick the surviving ticket, optionally\nnote a reason, and confirm. The current ticket's messages, watchers, and\ntags move to the target and this ticket is archived as a duplicate; you're\ntaken straight to the survivor. The button is hidden once a ticket has\nalready been merged. Surfaces the previously UI-less `support.ticket.merge`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.373Z","updatedAt":"2026-06-15T22:14:01.373Z"},{"id":"3135241d-675e-4696-9ca2-09dfcfd4b61e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-builder-field-width","type":"added","scope":"forms","summary":"The form builder can now set each field's width (full / half / third / quarter) to lay out columns.","body":"The field editor in the visual form builder gained a \"Width\" control — full,\nhalf, third, or quarter — so you can place fields side by side (e.g. first and\nlast name on one row) without touching JSON. The live preview and the public\nform render the chosen widths on a responsive grid that collapses to a single\ncolumn on phones.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:58.406Z","updatedAt":"2026-06-15T22:33:58.406Z"},{"id":"5760b49f-8e78-4abf-8913-17b9b52914b1","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"account-deletion-card-extracted","type":"changed","scope":"web","summary":"AccountDeletionCard extracted from settings/security.tsx (1937 → 1766 lines).","body":"Pure code-organisation follow-up to the TotpChallenge extract.\nThe settings/security.tsx route still hosts seven inline cards\n(SecurityScoreCard, TwoFactorCard, PasskeysCard, TrustedDevicesCard,\nLinkedAccountsCard, LoginHistoryCard, ApiKeysCard, DataExportCard,\nAccountDeletionCard).\n\nMoved the lightest of those — AccountDeletionCard — into its own\ncomponent file at `apps/web/src/components/account-deletion-card.tsx`.\nSame behaviour: same `iam.user.requestDeletion`/`cancelDeletion`\ncalls, same `'sign-out'` and `'account-lifecycle'` broadcasts (the\npost-audit kinds), same 4-second toast + redirect timing. The\nextraction needed no shared helpers, so the move is verbatim\nwith a one-line import update on the route.\n\nTwoFactorCard (620 lines, intertwined with MethodCard +\ndownloadBackupCodes) is the next big target but its dependency\ngraph is larger; not in scope today.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:31:33.247Z","updatedAt":"2026-06-15T20:31:33.247Z"},{"id":"ae382e6a-fd5f-455c-aae7-9126d12b09e6","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-ai-summarize-and-draft","type":"added","scope":"mailbox","summary":"AI panel in /mail — one-click thread summary + action items + AI-drafted reply pre-filled into the compose dock.","body":"M10 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. The mailbox is\nnow AI-native at the reader pane — a single click summarises\nthe open thread; a single click after that produces a draft\nreply that lands in the compose dock for the user to edit and\nsend.\n\n## Actions\n\nTwo new actions under `modules/mailbox/src/actions/`:\n\n### `mailbox.thread.summarize`\n\n- Input: `{threadId, model?}`. Default model = runtime default.\n- Output: `{threadId, summary, actionItems[], model}`.\n- Reads every non-trashed message in the thread, builds a\n  prompt capped at 30k chars (older messages truncated), asks\n  the AI runtime to emit JSON with `summary` + `action_items`.\n- Action items are FOR THE READER — what they need to do next,\n  not what others did. Empty when nothing's pending on their\n  side.\n- Gated by `mailbox:ai:use:own`.\n- Returns `dependency_failed` cleanly when no AI provider is\n  configured — UI falls back to \"Configure an AI provider in\n  Settings → AI\".\n- Read-only; no DB writes.\n\n### `mailbox.thread.draft_reply`\n\n- Input: `{threadId, intent?, tone?, model?}`. Tone enum:\n  `professional | friendly | concise | enthusiastic`.\n- Output: `{threadId, subject, body, model}`. The body is\n  plain text the compose dock pre-fills verbatim.\n- The AI is instructed NOT to include the quoted original —\n  the mail client appends it automatically.\n- Action does NOT send. The user reviews + edits + presses\n  Send in the compose dock (which goes through\n  `mailbox.message.send`).\n- Same ownership + impersonation gates as summarize:\n  - 404 if thread missing\n  - shared scope → 403 (routes to future\n    `mailbox.shared.thread.draft_reply`)\n  - cross-user → 403\n  - personal scope blocks impersonation (Q15)\n\n## UI — `AiPanel` in `apps/web/src/routes/mail.tsx`\n\nSits at the top of the reader pane, between the header and\nthe message stack. Renders in two states:\n\n- **Collapsed** (default): a faint purple \"Summarise with AI\"\n  pill. Click to open + auto-run the summarise mutation.\n- **Open**: shows the executive summary, an action-items list\n  with purple dots, and a \"Generated by {model}\" footer.\n  Re-summarise button + close button in the header.\n\nOnce the summary lands, a second surface appears: a free-form\n\"What should the reply say?\" input + a **Draft reply with AI**\nbutton. Clicking calls `mailbox.thread.draft_reply` with the\nintent + default `professional` tone; on success the compose\ndock opens with the same reply context the regular Reply\nbutton builds (correct `In-Reply-To` + `References` chain) but\nwith the subject + body pre-filled from the AI output.\n\n## Design\n\n- The panel uses a subtle purple gradient + 2px purple left\n  border per the Helios design system's \"AI-generated content\"\n  treatment (already used in chat, projects, CRM workflows).\n- Toasts errors via the standard `ActionCallError` → `toast.error`\n  path; never crashes the reader.\n- State resets when the user switches threads — no stale\n  summary leaking between conversations.\n- Mounts only when a thread is open (the panel lives inside\n  the reader's body view, not the chrome).\n\n## Test coverage (+17, 186 total mailbox-module tests)\n\n`thread-summarize.test.ts` (10 cases):\n- happy path with valid JSON\n- code-fence wrapped JSON tolerated\n- unparseable AI response → `dependency_failed`\n- runtime missing → `dependency_failed`\n- provider throws → `dependency_failed`\n- another user → `policy_denied`\n- impersonator on personal mailbox → `policy_denied`\n- shared scope on personal path → `policy_denied`\n- missing thread → `not_found`\n- missing permission → `deny`\n\n`thread-draft-reply.test.ts` (7 cases):\n- happy path with intent + tone propagated to prompt\n- code-fence wrapped JSON tolerated\n- unparseable → `dependency_failed`\n- runtime missing → `dependency_failed`\n- another user → `policy_denied`\n- impersonator on personal mailbox → `policy_denied`\n- missing permission → `deny`\n\n## Net behavior\n\nA user opens a long thread → clicks \"Summarise with AI\" → sees\n1-3 sentences of executive summary + the next steps pending on\nthem → types \"decline politely\" into the intent box → clicks\n\"Draft reply with AI\" → the compose dock pops up with a\nready-to-send reply, threading headers correct, the user edits\nthe last sentence + hits Send.\n\nThe mailbox now does what \"AI-native Work OS\" promised at the\ninbox: it understands the conversation and accelerates the\nresponse, without ever sending without the user's say-so.\n\nNext on the M-queue: M2-f shared inbox + admin business\nprovisioning; M1g/h IMAP + JMAP providers; later M10b\nsemantic search across all mail.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:31:33.260Z","updatedAt":"2026-06-15T20:31:33.260Z"},{"id":"0aa3d8df-fef0-48de-b429-995274277c0f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-admin-dialogs","type":"changed","scope":"roadmap","summary":"Admin roadmap flows replace 8 window.confirm / window.prompt calls with proper Dialog components.","body":"Every operator-confirm flow in `/saas/roadmap` is now a styled, accessible dialog instead of a native browser prompt — soft-delete, decline-reason, merge-into-target, external-tracker detach, Linear webhook disconnect, and the three Duplicates-tab buttons (Merge A→B, Merge B→A, Dismiss). Reuses the existing `ConfirmDialog` and `PromptDialog` primitives. Destructive actions get the danger-tinted confirm button; the dismiss prompt accepts an optional free-text note via multiline-aware `PromptDialog`.\n\nAudit findings **E1** and **E13** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:31:33.509Z","updatedAt":"2026-06-15T20:31:33.509Z"},{"id":"26affd85-56e3-451f-83e4-2a0e2a4c18fc","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"payroll-payslip-storage-cache","type":"changed","scope":"payroll","summary":"Payroll payslip PDFs now count against the org's plan-tier storage quota.","body":"Phase 6 of the unified Storage + Drive plan — payroll as the\nthird consumer to migrate onto the Storage actions (after sales\nand HRM documents). The legacy direct `getStorage().putObject()`\nwrite inside `renderPayslipPdf` is unchanged; this lands a\nfire-and-forget bridge that ALSO writes the bytes through\n`storage.object.put` so they:\n\n- Count against the org's plan-tiered storage quota (Storage\n  module's per-purpose + total caps now apply to payslips).\n- Surface in the per-purpose metering pie chart on\n  `/saas/storage`.\n- Power the future `/drive/System/HR/Payslips/<EmployeeName>/2026/Q2/...`\n  virtual folder once Phase 10 lands.\n\n`cachePayslipPdf(ctx, { payslipId, runId, employeeId,\npayslipNumber, version, bytes })` in `@helios/payroll/src/lib/\npayslip-storage-cache.ts` mirrors the HRM + sales bridges.\nIdempotency key `payroll.payslip.<payslipId>.v<version>` dedups\nthe meter event on re-render. Sha256 content-addressing\n(migration 0277_0278) collapses identical bytes within an org\nto a single row + refcount bump.\n\nWired into two call sites:\n- `modules/payroll/src/actions/payslip.ts` — operator preview /\n  re-download (`payroll.payslip.render_pdf` action)\n- `modules/payroll/src/actions/run.ts` — finalize loop\n  auto-renders every payslip's PDF when the run locks\n\nFire-and-forget: a Storage-module write failure does NOT break\nthe PDF download, the legacy upload, or the run finalize. The\ngetAction() guard keeps the payroll runtime loadable when\n`@helios/storage-module` isn't wired (unit tests, lightweight\nscripts).\n\n3 PGlite integration tests:\n- happy path: row + meter event + sharded counter all land\n- dedup: re-rendering refcount-bumps via sha256, single meter\n  event, single counter delta\n- policy-deny: actor without `storage:object:write:own` returns\n  null without throwing or writing rows\n\n`@helios/storage-module` added as a devDependency on\n`@helios/payroll` for the integration test's registerAction\nside-effect import. Production payroll runtime stays clean.\n\nSee `docs/plans/UNIFIED_STORAGE_AND_DRIVE/08_MODULE_INTEGRATION_GUIDE.md`\n§payroll for the Drive-tier-A surface plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.636Z","updatedAt":"2026-06-16T13:46:03.636Z"},{"id":"219683da-09d0-456b-acef-d65a030e1ef7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-public-page-polish","type":"changed","scope":"forms","summary":"Public forms now lay fields out in responsive columns and render with a modern card-based skin.","body":"The public form page got a visual overhaul that's distinct from the in-app\nforms. Fields now honour their layout width (full / half / third / quarter) on a\nresponsive 12-column grid — so name fields can sit side by side and forms read\nin tidy columns, collapsing to a single column on phones. Each section renders\nas a separated card with a clearer heading, giving multi-section forms obvious\nvisual grouping. The standalone page also gets roomier spacing and a single\nprominent full-width submit button. In-app (authenticated) forms keep their\ncompact skin.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.121Z","updatedAt":"2026-06-15T22:33:59.121Z"},{"id":"fef08863-755b-4961-9b54-91f7609a7b4e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-shared-roster","type":"added","scope":"mailbox","summary":"Shared-inbox member roster — list, add, remove members + list shared inboxes the actor sees.","body":"M2-f slice 1 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3.\nLands the admin-facing member-roster surface for shared\ninboxes (Front / Missive / Hiver-style team mail). Schema\nshipped in M0 (`mailbox_account_members` with partial unique\nindex on active members); this commit adds the action layer.\n\n## Actions shipped\n\n### `mailbox.shared.account.list`\n\n- Output per inbox: id, orgId, provider, emailAddress,\n  displayName, status, lastSyncedAt, memberCount, +\n  `memberRole` (the actor's role on this inbox, or null when\n  they're an admin viewing a non-member inbox).\n- Visibility:\n  - Plain member view: shared inboxes the actor is an active\n    member of.\n  - Catalog admin (holds `mailbox:account:shared:catalog`):\n    every shared inbox in the org.\n- Gated by `mailbox:account:read:shared`.\n\n### `mailbox.shared.member.add`\n\n- Input: `{accountId, userId, role, notificationPreferences?}`.\n- Roles: `observer | agent | admin` (mirrors\n  `mailbox_account_members.role`).\n- Default notification prefs: `{on_new_thread:false,\n  on_assigned:true, on_mentioned:true, on_status_change:false}`\n  — avoids notification fatigue on busy shared inboxes.\n- **Idempotent**: re-adding an active member returns the\n  existing row without mutating it (role changes use a\n  future `shared.member.update_role` action). Re-adding a\n  previously-revoked user creates a fresh row so audit\n  history is preserved.\n- Rejects: missing account, non-shared scope, cross-org\n  (each shared inbox is locked to its `org_id`), missing\n  target user.\n- Gated by `mailbox:account:manage_members:shared`.\n\n### `mailbox.shared.member.list`\n\n- Input: `{accountId, includeRevoked?}`. Default returns only\n  active members.\n- Joins `users` so the row carries `userEmail + userName`\n  for the UI to render without a second round-trip.\n- Cross-org reads denied even with the read permission.\n- Gated by `mailbox:account:read:shared`.\n\n### `mailbox.shared.member.remove`\n\n- Input: `{accountId, userId}`. Output: `{ok: true}`.\n- **Soft delete** — sets `revoked_at = now()`. The audit\n  trail is permanent; the member can be re-added later (a\n  fresh row).\n- **Dangerous** (gated by the AI runtime's confirmation\n  card when invoked by an agent).\n- Idempotent: revoking an already-revoked member is a no-op.\n- Gated by `mailbox:account:manage_members:shared`.\n\n## Architectural notes\n\n- All four actions are routed through `loadSharedAccount()`\n  which validates scope (`= 'shared'`) and org membership\n  (each shared inbox is locked to one org). The action\n  layer enforces both invariants even though the schema's\n  CHECK constraints already require them — defense in depth.\n- The `mailbox_account_members` table's partial unique index\n  `(account_id, user_id) WHERE revoked_at IS NULL` means an\n  active member can't be double-inserted. The action layer\n  short-circuits that with an idempotent read-first path\n  instead of catching the 23505.\n- Permission keys exist already in `packages/auth/src/roles.ts`\n  (the M0 catalog landed every shared-inbox permission ahead\n  of time): `mailbox:account:read:shared`,\n  `mailbox:account:manage_members:shared`,\n  `mailbox:account:shared:catalog`.\n\n## Test coverage (+19, 205 total mailbox-module tests)\n\n`shared-members.test.ts` (12 cases):\n- add — happy path, idempotency on re-add-active, fresh row\n  on re-add-after-revoke, rejects non-shared scope, rejects\n  cross-org, rejects missing target user, denies without\n  permission\n- list — active-only default, includeRevoked, cross-org denial\n- remove — soft delete, idempotent on no-active-membership\n\n`shared-account-list.test.ts` (7 cases):\n- plain member sees only inboxes they belong to (with\n  memberRole + memberCount)\n- member with memberships in multiple inboxes sees all\n- catalog admin sees every inbox in their org with\n  `memberRole: null` for non-member inboxes\n- cross-org isolation\n- soft-deleted accounts excluded\n- revoked memberships excluded from member-only view\n- denies without permission\n\n## Next slice\n\nM2-f slice 2 — shared-scope thread.list / thread.get /\nmessage.get / thread.mutations / message.send. The action\nlayer gates already reject `scope='shared'` and route to\n\"future shared actions\"; this slice adds those siblings so\nteam members can actually use a shared inbox end-to-end.\n\nThen M2-f slice 3 — the `/settings/mailbox` admin UI for\nprovisioning shared inboxes + managing the roster.\n\nAfter M2-f: M1g/h IMAP + JMAP provider adapters.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T21:01:16.641Z","updatedAt":"2026-06-15T21:01:16.641Z"},{"id":"3f787609-0c3b-4a71-afe1-8de199996549","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-unread-realtime-merge","type":"fixed","scope":"chat","summary":"Opening a channel now marks-read against the realtime-merged high-water seq, not the stale chat.channel.list cache value.","body":"The \"open a channel, look at the messages, switch — sidebar still shows\nunread\" bug. Root cause: the channel-view's eager mark-read effect used\n`channelQuery.data.currentSeq` directly. That value is whatever\n`chat.channel.list` returned on its last fetch — and any\n`message.new` envelopes that arrived **after** that fetch were never\nfolded back into it. The sidebar separately tracks the live high-water\nvia a `localMaxSeq` map fed by realtime envelopes, but the channel-view\nwasn't reading it.\n\nNet effect: a channel with 5 unread plus a 6th message that just arrived\nvia realtime marked-read at seq 5, leaving the 6th message permanently\nflagged on the server's recompute. The optimistic clear made the badge\ngo away momentarily, then the channel-list refetch resurrected it.\n\nTwo fixes:\n\n1. **Read the merged seq.** Exported `getLocalMaxSeq(channelId)` from\n   the sidebar so the channel-view's eager effect can target\n   `Math.max(row.currentSeq, getLocalMaxSeq(channelId))`. Defensive\n   `Math.max` so we never go below the cached value when no realtime\n   bump has been recorded yet.\n2. **Seed the optimistic counter store on the no-op path.** When the\n   eager effect early-returns because `target <= row.lastReadSeq`\n   (already caught up), it now calls `noteOptimisticMarkRead` too —\n   clears any stale `channel.unread_updated` envelope that beat the\n   channel-list refetch and bumped `localCounters` above zero with an\n   older `lastSeq`. The helper's idempotent guard makes this a free\n   no-op on the truly-caught-up case.\n\nThe messages-driven catch-up effect (the back-up path that fires when a\nnew message arrives mid-view) was already using the messages list's\n`max(seq)` directly, which IS the realtime-merged value (the\nuseRealtimeChannel hook patches the messages cache on every\n`message.new`). So it didn't need the fix.\n\n260 chat tests still pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.131Z","updatedAt":"2026-06-15T19:59:57.131Z"},{"id":"70f3c4b1-67ee-42fa-8bac-b7c379d01636","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-dialog-footer-polish","type":"changed","scope":"chat","summary":"ConfirmDialog/PromptDialog get the framed-footer treatment + the legacy ModalHeader's close glyph swaps to the Phosphor X.","body":"Polish pass that lands the chat module's confirmation + prompt dialogs on\nthe same visual structure every other chat modal already uses:\n\n- **Framed footer.** ConfirmDialog and PromptDialog previously rendered\n  their button row as raw `mt-5 flex justify-end` inside the body\n  padding. New-channel-modal, new-space-modal, and friends already use\n  the \"negative-margin + border-top + bg-emphasis\" pattern that\n  visually anchors the action row to the modal's bottom edge. The two\n  dialogs now match — `-mx-5 -mb-5` to escape the body padding,\n  `border-t border-[var(--border-subtle)]` plus `bg-[var(--bg-emphasis)]`\n  for the divider, `px-5 py-3` for the row chrome.\n\n- **Destructive `<Modal danger>`.** ConfirmDialog with `destructive` now\n  passes `danger` through to the underlying Modal so the faint red\n  border lights up at the modal edge in addition to the gradient on\n  the confirm button. Reinforces the destructive intent for users who\n  arrive at the dialog after a flurry of clicks.\n\n- **Phosphor close glyph.** The legacy `ModalHeader`'s close button\n  was the only remaining surface in the chat shell that used the raw\n  `✕` character — it renders inconsistently across fonts and weights\n  vs. the Phosphor `X` that every other dismiss affordance uses (chat\n  command palette, context menu submenus, the `@helios/ui` Modal's\n  `IconButton`). Swapped to `<XIcon size={12} weight=\"bold\" />` for\n  visual parity.\n\nStrictly cosmetic; no API or behaviour changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.099Z","updatedAt":"2026-06-15T19:59:57.099Z"},{"id":"a4512ac4-3069-4f2b-8288-590e0c96a6da","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-entity-panel-typefix","type":"fixed","scope":"support","summary":"Entity support panel compiles under stricter indexed-access typechecking.","body":"A stricter indexed-access TypeScript setting made the link-kind label\nlookup in the entity \"Support\" panel resolve to `string | undefined`,\nbreaking the build where it was passed as the translator fallback. The\nfallback is now coalesced to an empty string (the surrounding guard\nalready ensures the line only renders when a label exists). No behavior\nchange.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.448Z","updatedAt":"2026-06-15T19:59:57.448Z"},{"id":"da386532-5326-45cd-9ae2-92baab0e0769","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-signature-and-staging-fix","type":"fixed","scope":"mailbox","summary":"Fix staging migrate that broke on saas_plans.deleted_at; add per-account outgoing signature column + actions (UI in follow-up).","body":"## Critical: staging migrate fix\n\nThe 2026-06-16 priority commit `7b5290a1` shipped migration\n`0271_0272_saas_plans_mailbox_enabled_backfill.sql` with a\n`WHERE deleted_at IS NULL` filter — but `saas_plans` has no\n`deleted_at` column (it uses `is_archived`). Staging deploys\nfail at the migrate step with\n`column \"deleted_at\" does not exist`, blocking every push.\n\nFixed: the migration now updates every row unconditionally\n(`is_archived` plans are harmless to normalise; they're not\nsold). Still idempotent.\n\n## Signature column + actions\n\nNew migration `0272_0273_mailbox_account_signature.sql` adds\ntwo columns on `mailbox_accounts`:\n- `signature_text` (plain-text, used today by the compose dock)\n- `signature_html` (reserved for the M11 rich editor)\n\nTwo new actions in `modules/mailbox/src/actions/signature.ts`:\n\n- **`mailbox.account.get_signature({accountId})`** — returns\n  the two columns. Personal + business: actor must be the\n  owning user (impersonation blocked on personal). Shared:\n  any active member can read.\n- **`mailbox.account.update_signature({accountId, signatureText,\n  signatureHtml?})`** — pass `null` to clear. Personal + business:\n  actor must be the owning user. Shared: only admin members\n  can edit (so observers + agents share a single team\n  signature controlled by admins). Impersonation blocked on\n  personal.\n\nBoth gated at the policy layer by `mailbox:account:read:own`\nor `mailbox:account:manage_members:shared`; handler enforces\nper-scope ownership.\n\n## Schema journal repair\n\nA concurrent session dropped my idx-274 entry from\n`packages/db/drizzle/meta/_journal.json` while keeping the\nstorage-module migration. Restored my entry as idx 274 and\nre-sequenced the storage migration to idx 275 — the migration\nfiles themselves are unchanged.\n\n## Tests (+15, 281 total mailbox-module tests)\n\n`signature.test.ts` covers:\n- Personal: alice reads + updates; bob denied; impersonator\n  denied for both read + write\n- Business: alice (owner) can update\n- Shared: every member reads; only admin updates; agent +\n  observer + non-member all denied writes\n- Cross-org denied for shared\n- not_found on missing account\n- Policy denial without any usable perm\n\n## Net\n\nStaging deploys unblocked. Compose-dock signature wiring +\nsettings-UI editor land in the next commit (UI work was\nmid-flight when the staging failure interrupted; backend\nships first so the migrations land cleanly).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:37.515Z","updatedAt":"2026-06-16T00:03:37.515Z"},{"id":"c826d0a6-654a-4c16-8731-0f354a933f6d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"recruitment-offer-storage-cache","type":"changed","scope":"recruitment","summary":"Recruitment offer-letter PDFs now count against the org's plan-tier storage quota.","body":"Phase 6 of the unified Storage + Drive plan — recruitment as the\nfifth consumer to migrate onto the Storage actions (after sales,\nHRM documents, payroll payslips, and payments receipts). The\nlegacy direct `getStorage().putObject()` write inside\n`renderOfferPdf` is unchanged; this lands a fire-and-forget\nbridge that ALSO writes the bytes through `storage.object.put`\nso they:\n\n- Count against the org's plan-tiered storage quota.\n- Surface in the per-purpose metering pie chart on\n  `/saas/storage`.\n- Power the future `/drive/System/Recruitment/Offers/...`\n  virtual folder once Phase 10 lands.\n\n`cacheOfferPdf(ctx, { offerId, applicationId, candidateName,\nversion, bytes })` in\n`@helios/recruitment/src/lib/offer-storage-cache.ts` mirrors\nthe sales / HRM / payroll / payments bridges. Idempotency key\n`recruitment.offer.<offerId>.v<version>` matches the offer's\nown version model — re-rendering the same offer at the same\nversion refcount-bumps the existing row via sha256\ncontent-addressing (migration 0277_0278); a version bump\nproduces a new row.\n\nWired into the renderer's upload path so all three call sites\nget the bridge automatically:\n- `recruitment.offer.send` — final-state render, admin actor\n- `recruitment.offer.share_pdf` — operator share-link render,\n  admin actor\n- `recruitment.offer.public.lookup` — render-on-demand under a\n  public-token actor. Builds a `createSystemContext` with the\n  resolved orgId + `storage:object:write:own` permission so\n  the bridge fires even though the surrounding action runs\n  unauthenticated.\n\nThree correctness/safety carve-outs from the adversarial\npattern that landed with the payments bridge, applied here too:\n\n1. **PDF bytes are now deterministic per-(offer, version).**\n   `renderOfferPdf` previously stamped\n   `generatedAt = new Date().toISOString()` into the rendered\n   PDF, which broke sha256 dedup on every re-render. The doc\n   comment even claimed the renderer was deterministic; it\n   wasn't. `generatedAt` is now pinned to `documentDate`\n   (`sentAt ?? new Date()`), which IS stable for the offer's\n   lifetime once sent. Pre-send preview renders are still\n   non-deterministic via the `new Date()` fallback, but they\n   set `upload: false` and never enter the cache path.\n\n2. **Public-token actor uses a system context.** The\n   `offer-public.ts` render-on-demand path runs under a\n   token-resolved public actor with no standard permissions.\n   Without `createSystemContext`, the cache call would fail\n   the `storage:object:write:own` policy gate on every public\n   offer view. The system context is built with the orgId\n   recovered from the token row, NOT from caller input.\n\n3. **Filename + metadata sanitization.** `candidateName` is\n   supplied by candidates via the public apply form — IS\n   user-controlled. The sanitized value is reused for the\n   `metadata_json.candidateName` field because Postgres jsonb\n   columns reject strings containing the NUL byte, which\n   without this fix would silently turn the bridge into a\n   no-op for the offending offer while the legacy upload kept\n   working. Hostile-input integration test pins the behavior.\n\nFire-and-forget: a Storage-module write failure does NOT break\nthe PDF render, the legacy upload, the `pdf_storage_key`\ncolumn update, or the offer email. The `getAction()` guard\nkeeps the recruitment runtime loadable when\n`@helios/storage-module` isn't wired (unit tests, lightweight\nscripts).\n\n4 PGlite integration tests:\n- happy path: row + meter event + sharded counter all land\n- dedup: re-rendering the same offer version refcount-bumps\n  via sha256, single meter event, single counter delta\n- policy-deny: actor without `storage:object:write:own`\n  returns null without throwing or writing rows\n- hostile candidate name: NUL / control chars / path traversal\n  / leading dots all sanitized before landing as filename and\n  jsonb metadata\n\n`@helios/storage-module` added as a devDependency on\n`@helios/recruitment` for the integration test's\nregisterAction side-effect import. Production recruitment\nruntime stays clean — only the test wires the module.\n\nSee `docs/plans/UNIFIED_STORAGE_AND_DRIVE/08_MODULE_INTEGRATION_GUIDE.md`\n§recruitment for the Drive-tier-A surface plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.665Z","updatedAt":"2026-06-16T13:46:03.665Z"},{"id":"a10f7f2a-39a4-4e41-971c-4ef82ffc67d7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-thread-mutations","type":"added","scope":"mailbox","summary":"thread.mark_read + thread.star + thread.archive actions + matching keyboard shortcuts (E archive, S star, U unread) with optimistic UI.","body":"M5-b2 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Lands the\nmutation surface the inbox needs to feel like an actual mail\nclient — read/unread, starred, archive — with the matching\nkeyboard shortcuts and optimistic UI updates that hide every round\ntrip behind the mouse click.\n\n**Schema:** new `archived_at` column on `mailbox_threads` +\nsupporting partial index\n`mailbox_threads_account_inbox_idx ON (account_id, last_message_at)\nWHERE archived_at IS NULL AND deleted_at IS NULL` for the inbox-\nlist hot-path. Migration `0269_0270_mailbox_thread_archived.sql`\nis idempotent (`IF NOT EXISTS`).\n\nArchive ≠ delete:\n- `deleted_at` = trashed (hidden from every view).\n- `archived_at` = removed from the primary inbox view, still\n  accessible via \"All mail\" + search (M5-b3 filter UI).\n\n**Three new actions** in\n`modules/mailbox/src/actions/thread-mutations.ts`:\n\n- **`mailbox.thread.mark_read({threadId, unread})`** — flips\n  `mailbox_threads.has_unread` AND every message in the thread's\n  `is_unread` to match. Per the user's `unread` flag (so the same\n  action serves both \"mark read\" and \"mark unread\").\n- **`mailbox.thread.star({threadId, starred})`** — toggles\n  `mailbox_threads.has_starred`. Per-message star state stays as\n  the provider reported it; only the thread-level aggregate moves\n  here (matches the Shortwave/Superhuman UX where users star\n  conversations, not individual messages).\n- **`mailbox.thread.archive({threadId, archived})`** — stamps\n  `archived_at = now()` (or clears it when unarchiving). The\n  thread-list query filters archived rows out of the inbox view\n  via the new partial index.\n\nAll three require `mailbox:thread:write:own`. Share the same\nownership guard:\n- Thread must exist + not be soft-deleted\n- Owning account scope IN ('personal', 'business')\n- `account.user_id === ctx.actor.id`\n- **Personal scope blocks impersonation** (Q15 — re-checked on\n  every mutation, not just reads)\n- Shared scope routes to the future\n  `mailbox.shared.thread.*` actions\n\nProvider round-trip (Gmail UNREAD label add/remove; Graph isRead\nflip; Gmail INBOX label remove for archive) lands in M4\nincremental sync. For now mutations record local state; the next\nsync tick reconciles with the provider.\n\n**UI wired in `apps/web/src/routes/mail.tsx`:**\n\n- **Keyboard shortcuts** (active when no input is focused; no\n  modifier keys):\n  - `J` / `K` — next/prev thread (already shipped in M5-b1)\n  - `Esc` — close reader (M5-b1)\n  - **`E`** — archive the active thread + auto-advance to the\n    next thread in the list (the Superhuman \"fly through the\n    inbox\" feel)\n  - **`S`** — toggle star\n  - **`U`** — toggle read/unread\n\n- **Optimistic mutations.** Each action mutates the TanStack\n  Query cache immediately on click; the server call runs in\n  parallel. On error the cache is reverted + a toast surfaces\n  the reason. On archive success a small `'Archived'` toast\n  fires.\n\n- **Reader pane toolbar.** Three icon buttons (Star,\n  Read/Unread, Archive) in the thread header — tooltips include\n  the keyboard shortcut so users discover them. The star button\n  shows the filled amber icon when active.\n\n- **No-thread-selected hint** updated with the four shortcut\n  keys (`J / K` walk · `E` archive · `S` star · `U` read) so\n  the keyboard shortcuts are discoverable without docs.\n\n**Test coverage:** 10 new PGlite-backed tests (132 total in\nmailbox-module):\n\n- `mark_read` flips thread.has_unread + every message.is_unread;\n  reverse flip works; rejects another user; rejects impersonator\n  on personal mailbox\n- `star=true` sets has_starred; `star=false` clears it\n- `archive=true` stamps `archived_at` AND removes the thread from\n  the inbox list query; `archive=false` clears it + re-appears\n- Mutations deny without `mailbox:thread:write:own`\n- Returns `not_found` for unknown thread\n\n`apps/web` typecheck clean (0 errors).\n\n**Net behavior:** the inbox now feels like Shortwave. Press `J`\nthrough threads with one hand on the keyboard, `E` to fly past\nthe ones you're done with, `S` to star the interesting ones,\n`U` to mark something unread when you don't have time to read it\nright now — each action mutates the visual state instantly.\n\nNext: M5-b3 — Cmd+K command palette + folder/label sidebar tree\n+ attachment download presign; then M6 — compose / reply / send.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.053Z","updatedAt":"2026-06-15T19:59:57.053Z"},{"id":"7df6780c-0b5a-47cc-8e29-d29e2a28ddc1","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-compose-and-send","type":"added","scope":"mailbox","summary":"Compose and reply from /mail via mailbox.message.send — Gmail/Graph dispatch with threading headers + polished bottom-right dock.","body":"M6 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. The mailbox is no\nlonger read-only — users can compose new messages and reply to any\nthread without leaving `/mail`.\n\n## Action: `mailbox.message.send`\n\nNew action in `modules/mailbox/src/actions/message-send.ts`.\n\n- **Input.** `accountId`, `to[]`, optional `cc[]` / `bcc[]` /\n  `replyTo`, `subject`, `bodyHtml | null`, `bodyText | null`, plus\n  optional threading context `threadId`, `inReplyToMessageId`,\n  `references[]`.\n- **Output.** `providerMessageId`, `messageIdHeader`, `threadId`,\n  `acceptedAt`.\n- **Policy.** Gates on `mailbox:message:send:own`.\n- **Dangerous.** Marked `dangerous: true` so the AI runtime\n  requires explicit confirmation before invoking — autonomous send\n  is never possible.\n\nPipeline:\n\n1. Ownership: account must exist + not soft-deleted; actor must\n   own it; shared-scope routes to the future\n   `mailbox.shared.message.send`; personal scope blocks\n   impersonation (Q15).\n2. Status guard: rejects `auth_expired` / `failing` / `disabled`\n   accounts with `dependency_failed`.\n3. Token decrypt: via `bindingFromRow` (AES-256-GCM envelope from\n   `@helios/email`).\n4. Provider lookup: `getMailboxSyncRuntime().providers[account.provider]`.\n5. Send: calls the provider's `send(account, request)`.\n6. `ProviderError` mapping: `auth_expired → validation_failed`,\n   `quota_exceeded → rate_limited`, `network|provider_failed →\n   service_unavailable`, anything else → `internal_error`.\n\nThe sent message materialises in `mailbox_messages` on the next\nincremental sync tick — the provider's API already returned the\nid, so synchronous local write is unnecessary.\n\n**13 PGlite-backed tests** cover happy path (personal +\nbusiness), threading headers passthrough, ownership rejection,\nimpersonation fence on personal scope, shared-scope path-routing\nguard, inactive-account guard, missing-account, missing-permission,\neach ProviderError → ActionErrorCode mapping, and missing-provider\nin the runtime.\n\n## UI: Compose dock in `/mail`\n\nNew `ComposeDock` component in `apps/web/src/routes/mail.tsx` —\nthe polished bottom-right docked panel (Gmail / Shortwave style)\nthat opens with two entry points:\n\n- **`C` keystroke** OR clicking the new amber **Compose** button\n  in the account sidebar → opens a blank compose pre-bound to the\n  active mailbox.\n- **`R` keystroke** OR clicking the new **Reply** button in the\n  thread reader header → fetches the active thread's last\n  message, builds `inReplyToMessageId` + `references` from the\n  RFC 5322 headers, pre-fills `To` from the message's `From`,\n  and prepends `Re: ` to the subject if not already there.\n\nLayout — 520px wide, anchored bottom-right with shadow + ring,\ndesigned to feel as native as the rest of the inbox:\n\n- Sticky header with the kind label (`New message` / `Reply` /\n  `Forward`) + close (X) button.\n- From-account selector (only when the user has >1 mailbox).\n- `To` field — comma/semicolon/newline separated; supports both\n  bare email and `\"Name\" <email>` syntax (RFC 5322).\n- `Cc/Bcc` toggle — collapsed by default; click to reveal.\n- Subject — borderless 14px input.\n- Body — borderless 240px+ textarea (rich editor lands in M11).\n- Footer with \"From {emailAddress}\" hint + amber Send button.\n\nKeyboard:\n\n- **`⌘⏎` / `Ctrl⏎`** → send.\n- **`Esc`** → close (drops the draft; local-draft persistence\n  comes in M8).\n\nThe send button is disabled until there's at least one valid\nrecipient AND (subject or body content) — prevents accidental\nempty sends. On success, the dock closes + a toast confirms\n\"Reply sent\" / \"Message sent\". On error the dock stays open with\nthe user's draft intact so they don't lose typing.\n\nThe reader pane's keyboard-hint footer is updated with the two\nnew shortcuts (`C` compose · `R` reply) so they're discoverable.\n\n## Net behavior\n\nA user with a connected Gmail or Microsoft 365 account can now\nread AND send from `/mail` — keyboard-first (`J/K` walk, `E`\narchive, `S` star, `U` read, **`C` compose, `R` reply**) and\nmouse-first via the sidebar Compose button + reader Reply button.\nThe mailbox is now a real mail client.\n\nNext: M4 — incremental sync (Gmail Pub/Sub + Graph\nsubscriptions + watch-renewal cron) so sent messages and new\ninbound mail land in the local DB within seconds instead of on\nthe 30s poll cadence; then M5-b3 — Cmd+K command palette +\nfolder/label sidebar tree + attachment download presign.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.838Z","updatedAt":"2026-06-15T19:59:57.838Z"},{"id":"ac041241-3fbd-471c-9210-36c7378816be","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-ticket-counter-driver","type":"fixed","scope":"support","summary":"Ticket reference-number allocation works across Postgres drivers.","body":"`allocateTicketReferenceNumber` assumed the raw `RETURNING` result was a\nbare row array (postgres-js's shape) and threw \"reference counter upsert\nreturned no rows\" under drivers that wrap results in `{ rows: [...] }`\n(node-postgres / the PGlite test harness). It now normalizes both shapes.\nThis also unblocked the support module's first DB integration tests, which\nexercise the requester auto-link + `:my_company` read scope against real\nPostgres.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:12:13.520Z","updatedAt":"2026-06-15T20:12:13.520Z"},{"id":"b948b533-559f-425d-83e0-dcdf33093aeb","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-feed-banner-migration","type":"changed","scope":"web","summary":"VerifyEmailBanner reads the unified onboarding feed (Phase 5.B/2).","body":"Second UI consumer migration onto `platform.onboarding.feed.list`.\n\nThe chrome banner now reads the unified feed and derives its\ntwo modes from feed items:\n\n- **Mode 1 (red \"verify email\"):** triggers when the\n  `user:verify_email` feed item is `blocking: true` and not yet\n  completed. The iam resolver sets `blocking: true` exactly when\n  `users.must_verify_email = true` AND `email_verified = false`\n  — same gate the legacy `mustVerifyEmail && !emailVerified`\n  check enforced.\n- **Mode 2 (amber \"finish setting up\"):** triggers when at least\n  one `kind: 'user'` feed item is `enforced: true` AND\n  `progress.percent < 100`. The legacy `isComplete` flag is\n  replaced by the percent check (skipped counts as resolved —\n  same display semantic as the wizard).\n\n`SecurityScoreCard` was checked; it reads `useMe()` + Better-\nAuth's passkey/session counts directly, not the onboarding\naction, so no migration needed.\n\n`/onboarding` wizard route migration is deferred until Phase\n5.C ships the write-side `feed.complete_step` / `skip_step`\ndispatchers — the wizard performs mutations that need a\nsingle-action target.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T21:01:17.408Z","updatedAt":"2026-06-15T21:01:17.408Z"},{"id":"9cd3beb6-0b5e-4937-851f-91d10ec351bd","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-dedup-sweep-prune-stale","type":"fixed","scope":"roadmap","summary":"Dedup sweep now prunes candidate rows whose features were soft-deleted (e.g. merged away).","body":"The duplicate-candidates table accumulated rows whose features had been merged away. The admin queue filtered them out via an innerJoin so users never saw them, but the table grew unboundedly. The daily sweep now starts with a single DELETE that prunes any candidate where either side has `deleted_at IS NOT NULL`. Audit finding **A6** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.130Z","updatedAt":"2026-06-15T19:59:57.130Z"},{"id":"f27030ae-a63d-4637-ac44-1435bb40515a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-status-component-form","type":"changed","scope":"support","summary":"Status-page component editor uses the standard form stack with inline validation.","body":"The Settings → Support → Status component create/edit dialog moved from a\nhand-rolled `useState`-per-field form to the shared `useAppForm` + `<Form>`\nZod stack, with inline validation on the name field and a consistent submit\nstate. Payload and create-vs-update behaviour are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.590Z","updatedAt":"2026-06-15T19:59:57.590Z"},{"id":"0ed28f8a-a222-476a-9f8b-d1a7d6bd6af6","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-sync-fixes","type":"fixed","scope":"support","summary":"Macro usage counter is now race-safe and KB publishes emit a re-index signal.","body":"Two support data-consistency fixes: the macro \"used N times\" counter is now\nincremented atomically in SQL instead of read-then-write, so concurrent\nmacro runs (e.g. a bulk apply) no longer lose increments; and publishing a\nKB article now emits the `support.kb.article_published` event that was\ndefined but never fired, so AI knowledge sources pointing at the article\ncan be re-indexed once that consumer is wired.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.682Z","updatedAt":"2026-06-15T19:59:57.682Z"},{"id":"57d7e90c-b1c9-47b8-94fd-e14593aab100","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-feed-widget-migration","type":"changed","scope":"web","summary":"SecurityOnboardingWidget reads the unified onboarding feed instead of iam.user.onboarding.get; passkey pseudo-step removed (Phase 5.A.6).","body":"Phase 5.A.6 — proof consumer migration. The dashboard's\n`SecurityOnboardingWidget` now reads\n`platform.onboarding.feed.list` instead of the legacy\n`iam.user.onboarding.get` + an out-of-catalog `add_passkey`\npseudo-step.\n\nThe audit Phase 2 work already made passkey a satisfier for\nthe `enable_two_factor` step server-side, so the widget no\nlonger needs to splice a synthetic \"Add a passkey\" row at the\nclient. The unified feed surfaces what the resolver returns;\nthe widget renders.\n\nSide benefits:\n- The widget now picks up `org` entries too (brand,\n  invite_teammates, modules, workspace_defaults, billing) when\n  the actor is an org owner with unfinished setup.\n- Skipped entries count as resolved (visually line-through).\n- `platform.onboarding.feed.list` joins the cross-tab\n  invalidation list for `'two-factor'`, `'passkeys'`,\n  `'profile'`, `'onboarding'` broadcasts. The legacy\n  `iam.user.onboarding.get` query key stays in the list during\n  the migration window (Phase 5.D drops it).\n\nNo iam test changes — the legacy action stays alive for the\nother consumers we'll migrate in Phase 5.B (verify-email\nbanner, security score card, onboarding wizard route).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T21:01:17.709Z","updatedAt":"2026-06-15T21:01:17.709Z"},{"id":"59ad28b6-1473-44e1-8d74-40db59432a8d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-cron-resilience-and-gate-tests","type":"changed","scope":"roadmap","summary":"Dedup sweep tolerates per-seed failures, trending log pins the half-life, votePlanGate gets unit tests.","body":"Three small reliability fixes batched together:\n\n- **Dedup sweep keeps running when one seed throws.** The per-seed loop is now wrapped in a try/catch that increments a `failedSeeds` counter and logs the bad row instead of aborting the whole tick. The cron lights up at the first sign of partial failures so persistent issues surface without being buried by the heartbeat path.\n- **Trending cron logs pin the half-life value the SQL actually used.** Operators tweaking `ROADMAP_TRENDING_HALF_LIFE_DAYS` mid-run can correlate a log line to the actual decay window applied, not the env value at log time.\n- **`checkVotePlanGate` gets 8 unit tests.** Covers empty gate / platform sentinel / null orgId / no subscription / wrong plan / matching plan / trialing subscriptions / past_due denial. The Phase R-3 vote gate is now exercised at every important branch.\n\nAudit findings **F4**, **F5**, **H2** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.050Z","updatedAt":"2026-06-15T22:14:01.050Z"},{"id":"63396b4e-46d8-4735-9bc9-b488cb62184e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-gmail-client-bootstrap","type":"added","scope":"mailbox","summary":"Gmail provider's testConnection + bootstrap pass + HTTP client with auth refresh / 429 retry / 5xx backoff.","body":"M1c from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1. Ships the\nHTTP-bound half of the Gmail provider — the client wrapper that\nhandles every flake mode (stale token, rate limit, server error) +\nthe two methods that exercise it (`testConnection`, `bootstrap`).\n`syncIncremental`, `renewWatch`, `send`, `fetchAttachment` still\nthrow `ProviderError({kind:'unsupported'})` — each lands in its own\nfollow-up commit.\n\n**`packages/mailbox-sync/src/providers/gmail/client.ts`** — `GmailClient`\nclass with `request({binding, method, path, query?, body?, onTokenRefreshed?})`:\n\n- **Proactive refresh** — if `accessTokenExpiresAt` is within 60 s,\n  refresh BEFORE sending (saves a round-trip).\n- **401 → refresh + retry once.** Some Gmail endpoints reject a\n  stale-but-not-yet-expired token; the second attempt uses the\n  freshly minted token.\n- **429 → respect Retry-After.** Parses RFC 7231 §7.1.3 header\n  (delta-seconds or HTTP-date) and waits the appropriate amount\n  before retrying.\n- **5xx → exponential backoff retry.** `500ms × 2^attempt + jitter`,\n  up to `maxRetries` (default 4).\n- **Network errors → retry with backoff.** Caught from `fetch`\n  rejection; coerced to `ProviderError({kind:'network'})` on terminal\n  failure.\n- **Response mapping** — 401/403 → `auth_expired`, 404 → `not_found`,\n  410 → `gone` (Gmail returns 410 for stale historyId — caller uses\n  this to trigger a rebootstrap), 4xx → `bad_request`, 5xx →\n  `provider_failed`, 429 → `quota_exceeded`.\n- **Token-refresh callback** — `onTokenRefreshed` fires once per\n  refresh with `{accessToken, expiresAt, scopes}` so the caller can\n  re-encrypt + persist to `mailbox_accounts`.\n\n**`packages/mailbox-sync/src/providers/gmail/provider.ts`** —\n`GmailProvider` implementing `MailboxProvider`. M1c methods:\n\n- **`testConnection(account)`** — GET `/users/me/profile`. Returns\n  `{ok:true, metadata:{displayName, emailAddress, messageCount?}}`.\n- **`bootstrap(account, opts?)`** — async generator over paginated\n  `users.messages.list` → batched `users.messages.get` (5 concurrent,\n  ~25 quota-units/sec / user — well under Gmail's 250 limit).\n  - Fetches `users.profile` first to capture the start-of-bootstrap\n    `historyId` (returned as `nextWatermark.historyId` on every page;\n    constant so incremental sync picks up exactly where bootstrap\n    ended).\n  - Optional `opts.sinceDays` adds `q=newer_than:<N>d` to the list\n    query — used by the M4 recovery-rebootstrap path.\n  - Yields `{messages: RawMessage[], nextWatermark}` per page.\n- **`syncIncremental` / `renewWatch` / `send` / `fetchAttachment`** —\n  reject with `ProviderError({kind:'unsupported', message:'lands in M1d/e/f'})`.\n\n**12 new provider tests** cover the full surface: profile-fetch\nmetadata, Bearer auth header, list pagination drainage, `messages.get`\nbatching, RawMessage translation, 401 → refresh + retry (with the\n`onTokenRefreshed` callback fired), Retry-After honored on 429, 410\nmapped to `gone`, 404 mapped to `not_found`, unsupported-method\nrejection, proactive refresh on near-expiry token, `sinceDays` query\nparam.\n\nTotal package coverage: **63 tests passing** (9 error-helpers + 25\ntranslate + 17 auth + 12 provider). Typecheck clean.\n\nNext: M1d — `syncIncremental` (`users.history.list` with rebootstrap\non stale-historyId via the 410 → `gone` path), `send`, and\n`fetchAttachment`. M1e — `renewWatch` + Pub/Sub topic setup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-gmail-client-bootstrap.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"c44ca81f-7259-404f-bdcb-a267280473ae","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-gmail-sync-send-attach","type":"added","scope":"mailbox","summary":"Gmail syncIncremental (history.list + rebootstrap-on-stale) + send (MIME builder) + fetchAttachment.","body":"M1d from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1. Closes out the\n**read + write** half of the Gmail provider. After this commit only\n`renewWatch` (push subscription / Pub/Sub topic — M1e) remains\nunimplemented.\n\n**`syncIncremental(account, watermark)`** — the per-tick delta pass:\n\n- GET `/users/me/history?startHistoryId=<watermark.historyId>`,\n  paginated. Aggregates across pages.\n- Distinguishes `messagesAdded` / `messagesDeleted` / `labelsAdded` /\n  `labelsRemoved` HistoryRecord entries → projects them into our\n  `SyncResult` shape (`messages`, `deletedProviderMessageIds`,\n  `labelChanges` with per-message added/removed label arrays).\n- Fetches full bodies for newly-added messages via the same batched-5\n  `messages.get` path bootstrap uses.\n- **410 → rebootstrap.** Gmail returns 410 when `startHistoryId` is\n  too stale (typically >7 days). The provider catches the\n  `ProviderError({kind:'gone'})` and returns `hint:'rebootstrap'` so\n  the worker switches to bootstrap-over-last-30-days.\n- Empty watermark → `hint:'rebootstrap'` immediately (caller invoked\n  sync before bootstrap finished).\n\n**`send(account, request)`** — outbound dispatch:\n\n- New module `mime.ts` builds an RFC 5322 message from the\n  provider-neutral `SendRequest` shape. Handles:\n  - text/plain + text/html alternative bodies\n  - file attachments (inline + non-inline) with `Content-Disposition`\n    + RFC 2392 `Content-ID` for inline images\n  - `multipart/alternative` inside `multipart/mixed` when both bodies\n    + attachments present\n  - In-Reply-To + References for threading\n  - Standard envelope (From / To / Cc / Bcc / Reply-To / Subject /\n    Date / Message-ID)\n  - Display-name quoting when name contains `, < > ( ) ; \\\\` or quotes\n  - Storage-keyed attachments via injectable resolver — keeps big\n    PDFs out of the process heap\n- POSTs `/users/me/messages/send` with `{raw: base64url(mime), threadId?}`.\n- Returns `{providerMessageId, messageIdHeader, threadId, acceptedAt}`.\n\n**`fetchAttachment(account, ref)`** — lazy attachment fetch:\n\n- GET `/users/me/messages/<msgId>/attachments/<attId>` returns\n  base64url-encoded bytes.\n- Decodes and returns a `Buffer`. Caller decides whether to stream\n  to object storage or buffer in memory.\n\n**Test coverage:** 88 tests total across the package now (up from\n63): 9 error + 25 translate + 17 auth + **17 new MIME builder** + 20\nprovider (12 prior + 8 new). MIME tests cover every body / attachment\ncombination, header quoting, In-Reply-To threading, storage-resolver\ninjection. New provider tests cover history-list pagination,\nlabel-delta aggregation, 410 → rebootstrap, send MIME round-trip,\nfetchAttachment base64url decode.\n\nNext: M1e — `renewWatch` + Pub/Sub topic registration + the worker's\nwatch-renewal cron. Then M2 — account connect flow + UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-gmail-sync-send-attach.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"9de0b91d-5b59-4ceb-beca-4061cd5905f9","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-gmail-translate-auth","type":"added","scope":"mailbox","summary":"Gmail message translator (Gmail JSON → RawMessage) + OAuth token refresh helper — pure-logic pieces of the Gmail provider.","body":"M1b from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1. Ships the two\npure-logic pieces of the Gmail provider — translator + auth — so the\nHTTP-bound pieces (bootstrap iterator, incremental delta, send,\nfetchAttachment) in M1c land against well-tested foundations.\n\n**`packages/mailbox-sync/src/providers/gmail/translate.ts`** — pure\ntransformation of Gmail's `users.messages.get` JSON shape into our\nprovider-neutral `RawMessage`. Handles:\n- Multipart MIME walking (text/plain + text/html bodies; inline\n  + attached files with Content-IDs)\n- RFC 5322 envelope headers (From / To / Cc / Bcc / Reply-To /\n  Subject / Date / Message-ID / In-Reply-To / References)\n- Permissive address parsing — `\"Smith, John\" <j@x>` keeps the\n  comma inside the quoted display name\n- base64url body decoding (RFC 4648 §5)\n- Gmail label IDs → `providerLabels` + `isUnread` / `isStarred`\n- RFC 8601 Authentication-Results → `authResults` SPF/DKIM/DMARC\n  verdicts\n\n**`packages/mailbox-sync/src/providers/gmail/auth.ts`** — Gmail\nOAuth helpers:\n- `refreshGmailAccessToken({refreshToken, fetchImpl?})` — POSTs the\n  Google OAuth token endpoint. Maps Google's error responses onto\n  our `ProviderError` taxonomy: `invalid_grant` → `auth_expired`,\n  429 → `quota_exceeded` with `retryAfterMs`, 5xx → `provider_failed`,\n  4xx → `bad_request`, fetch reject → `network`.\n- `checkGmailScopes(scopes)` — validates the granted scopes\n  include `gmail.readonly` + `gmail.modify` + `gmail.send`.\n- `parseRetryAfter(header)` — RFC 7231 §7.1.3 parsing\n  (delta-seconds OR HTTP-date).\n- `isAccessTokenFresh(expiresAt, bufferMs?)` — predicate for the\n  bootstrap / sync paths to decide whether to refresh first.\n\n**Test coverage:** 51 tests in `packages/mailbox-sync` (9 error\nhelpers + 25 translate + 17 auth) covering envelope parsing,\nmultipart body extraction with inline-image Content-IDs, base64url\ndecoding edge cases, OAuth success + every ProviderError branch\n(auth_expired / quota_exceeded / provider_failed / network /\nunsupported when env credentials missing), scope validation, and\nretry-after parsing.\n\nTranslator is **pure** — no network, no DB, no env reads. The\nHTTP-bound auth helper is **fetch-injectable** so tests run against\nmocked Responses without real OAuth calls.\n\nNext: M1c — Gmail HTTP client (bootstrap iterator + incremental\n`history.list` + `users.watch` setup).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-gmail-translate-auth.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"bdd9a4bf-1810-4a40-ab98-87a4ae073477","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-gmail-watch","type":"added","scope":"mailbox","summary":"Gmail renewWatch + stopWatch — completes the GmailProvider. Push subscriptions via Cloud Pub/Sub with INBOX-only default filter.","body":"M1e from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1. Implements the\nlast two unimplemented methods on `GmailProvider` and ships the\noperator config for push subscriptions. With this commit landed,\n**every method on `MailboxProvider` is implemented for Gmail** —\nthe next milestone (M1f) shifts to the Microsoft Graph provider.\n\n**`renewWatch(account)`** — POSTs `users.watch` with the Pub/Sub\ntopic name from env. Returns `{id, expiresAt}` (Gmail's expiration\nis in millis-since-epoch as a string; parsed + wrapped to `Date`).\nDefault label filter is **INBOX-only** so the worker doesn't get\nwoken up for sent/spam/trash mutations the user doesn't care about\n— operators override via env:\n\n- `MAILBOX_GMAIL_PUBSUB_TOPIC` — required. Format\n  `projects/<gcp-project>/topics/<topic>`. Operators register the\n  topic in GCP + grant Gmail publish permission per\n  developers.google.com/gmail/api/guides/push.\n- `MAILBOX_GMAIL_WATCH_LABELS` — optional. Defaults to `INBOX`.\n  Set to `ALL` to watch every label change (more notifications +\n  cost), or a comma-separated include list (`INBOX,IMPORTANT`),\n  or an exclude list (`EXCLUDE:SPAM,TRASH`).\n\nReturns `null` when `MAILBOX_GMAIL_PUBSUB_TOPIC` is unset — that's\nhow operators opt OUT of push (local dev, low-volume tenants). The\nsync engine falls back to polling at the plan's\n`mailbox.sync.poll_interval_seconds_minimum`.\n\n**`stopWatch(account)`** — POSTs `users.stop` to tear down the\nsubscription on disconnect. Treats 4xx as \"already stopped\" and\nsilently succeeds (the most common 4xx is Gmail saying the watch\nwas never registered or expired naturally — the caller wants to\nrelease the local watch_subscription_id either way).\n\n**Test coverage** added 7 new tests (95 total in mailbox-sync):\n- renewWatch happy path with default INBOX filter\n- renewWatch with `WATCH_LABELS=ALL` (no labelIds in request body)\n- renewWatch with `EXCLUDE:SPAM,TRASH` filter\n- renewWatch returns null when topic env missing\n- renewWatch throws `provider_failed` on response missing expiration\n- stopWatch happy path\n- stopWatch silently absorbs 4xx\n- stopWatch re-throws non-4xx errors\n\n**What's still missing for an operational Gmail push pipeline**\n(NOT part of this commit — landing in M4):\n\n1. **Worker watch-renewal cron** that calls `renewWatch` on every\n   active mailbox_accounts row at 60% of TTL.\n2. **Pub/Sub push webhook** at `/api/mailbox/webhooks/gmail` in\n   `apps/web` that verifies the Pub/Sub JWT, decodes\n   `{emailAddress, historyId}`, and enqueues a `mailbox.sync.tick`\n   action.\n\nThose two pieces depend on M2 (`mailbox.account.connect` action +\nthe disconnect path that calls `stopWatch`) and M4 (the\n`mailbox.sync.tick` action). M1e gets us provider-side ready.\n\nNext: M1f — Microsoft Graph provider (parallels M1a-e structure:\ninterface impl + translator + auth + HTTP client + bootstrap +\nsyncIncremental + send + fetchAttachment + subscription).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-gmail-watch.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"acdfbe3f-b903-4efd-a370-937f14587485","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-module-scaffold","type":"added","scope":"mailbox","summary":"New mailbox module scaffold + mailbox.account.classify_at_connect action (scope auto-detect at OAuth connect).","body":"M0 commit #4 from `docs/plans/MAILBOX_M0_DESIGN.md` §12. Scaffolds the\ntwo new packages + ships the first real `mailbox.*` action.\n\n**Two new packages:**\n\n- `modules/mailbox/` — business-logic module (actions, schemas,\n  policies, jobs). Has its own `CLAUDE.md` documenting the three-scope\n  ownership model + the \"no `modules/*` imports\" rule.\n- `packages/mailbox-sync/` — provider-neutral sync engine. Ships the\n  `MailboxProvider` interface, `Watermark` / `RawMessage` / `ConnectInput`\n  types, and the runtime-singleton pattern (`setMailboxSyncRuntime` /\n  `getMailboxSyncRuntime` / `requireMailboxProvider`) so M1's Gmail-first\n  MVP slots in cleanly.\n\n**First real action:** `mailbox.account.classify_at_connect`. Pure\nsynchronous resolver — no OAuth interaction, no side effects, no token\nwrites. Called by the M2 connect UI BEFORE the OAuth popup so it can\npreview the recommended `scope` (`personal` vs `business`) and show the\ndisclosure modal when business is auto-detected.\n\nImplements the §3.5.1.1 resolver from\n`MAILBOX_ARCHITECTURE_DECISION.md`:\n\n1. **`no_match`** — domain not in `organization_domains` → personal,\n   no override.\n2. **`verified_domain`** — verified + `is_business_mail_domain=TRUE`\n   → business, no override (admin has locked this domain).\n3. **`verified_domain_not_business_mail`** — verified but flag=FALSE\n   → personal default with user-toggle to reclassify (borderline case).\n\nThe same resolver is re-run authoritatively by the OAuth callback\nhandler in M2 — a tampered URL parameter cannot escalate scope.\n\nAlso ships:\n- `mailbox.health.ping` — placeholder action proving the registry sees\n  the module. Removed in M1 when `mailbox.account.connect` lands.\n- `modules/mailbox/src/policies/index.ts` — empty barrel in M0; the\n  substantive policy helpers (`requireMailboxAccountAccess` /\n  `requireMailboxQuota` / `requirePlanFeature`) land in commit #5.\n- `modules/mailbox/src/jobs/index.ts` — empty registrar so the worker\n  boot can import `registerMailboxJobs({ db })` without conditional\n  logic. Subscribers + crons land in M3+.\n\n**Test coverage:** 7 PGlite-backed integration tests in\n`scope-resolver.test.ts` covering all three branches plus edge cases:\nunverified-but-flagged domain doesn't classify as business; soft-deleted\ndomains are invisible; email-address case is normalised; defective\ninput falls back to personal.\n\nNo `modules/*` imports — `modules/mailbox/` depends only on\n`@helios/actions`, `@helios/auth`, `@helios/db`, `@helios/email`,\n`@helios/events`, `@helios/mailbox-sync`, `@helios/saas`. Maintains the\nstandalone-SaaS-extraction readiness from `MAILBOX_BUILD_PLAN.md` §1.5.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-module-scaffold.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"a6f4acb7-6521-44ca-803c-1ef8e92a988b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-permissions-catalogue","type":"added","scope":"mailbox","summary":"29 mailbox:* permissions + compliance:archive:read, with role-blueprint wiring for owner / admin / manager / employee.","body":"Adds the mailbox module's permission catalogue (M0 commit #2 from\n`docs/plans/MAILBOX_M0_DESIGN.md` §12). No user-visible functionality yet —\nthe keys are in place so the policy helpers (next commit) and later real\nactions compose against a stable surface.\n\n**Three permission tiers** mirroring the §3.5 three-scope ownership model:\n\n- **User-self (`:own`)** — 12 keys covering both `scope='personal'` AND\n  `scope='business'` rows where the user is the primary operator. The\n  difference between the two scopes lives at the policy layer\n  (`modules/mailbox/src/policies/scope.ts` — landing in commit #5), not\n  the permission layer.\n- **Business-admin lifecycle** — 4 keys for org-admin provisioning,\n  reassign, lock-scope, and read-only catalog. **No content access** —\n  business mailbox content reads flow through `iam.impersonate.start`\n  with audit.\n- **Shared-inbox** — 13 keys for create, member-management, member action\n  set (read / write / send / draft / label / rule / AI / link / comment),\n  assignment, transfer-ownership.\n\nPlus `mailbox:ai:use:bulk` (>50 thread triage cap) and `compliance:archive:read`\n(Q13 Compliance Pack SKU — separate namespace; never in any blueprint).\n\n**Structural ACL invariant:** there is intentionally NO `mailbox:*:any` key.\nReading another user's personal mailbox is structurally impossible from any\nrole. Enforced by a dedicated test in\n`modules/iam/src/lib/mailbox-permissions.test.ts`.\n\n**Blueprint defaults:**\n\n- **Owner** — every mailbox key via `ALL_NON_PLATFORM_PERMS`, minus\n  `compliance:archive:read` (explicit grant only via the Compliance Pack\n  SKU).\n- **Admin** — owner minus `lock_scope:business` + `transfer_ownership`\n  (owner-only by default; org policy can custom-grant).\n- **Manager** — every `:own` + can create shared inboxes for their team\n  + shared-member action set + `:assign`. Does NOT get the business-mailbox\n  admin lifecycle (provision / reassign / lock_scope / list).\n- **Employee** — every `:own` + shared-member action set.\n- **Client** — nothing.\n\nTwo new constants in `packages/auth/src/roles.ts`:\n- `MAILBOX_OWN` — the 12-key user-self set, spread into manager + employee.\n- `MAILBOX_SHARED_MEMBER` — the shared-member action set.\n\nThe existing `modules/iam/src/lib/role-catalog.test.ts` drift guard is\nupdated with the new owner + admin omissions. The new\n`mailbox-permissions.test.ts` adds 9 focused mailbox-specific assertions\n(catalogue completeness, descriptions, no-`:any` invariant, blueprint\ncoverage per role).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-permissions-catalogue.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"65fa12f1-f477-445c-adb7-2d6a61ac1612","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-plan-features","type":"added","scope":"mailbox","summary":"21 mailbox feature-catalog entries gating personal / business / shared mailboxes per Odexy plan tier.","body":"Adds the mailbox module's plan-feature gates (M0 commit #3 from\n`docs/plans/MAILBOX_M0_DESIGN.md` §12). New orgs on `free` / `starter` /\n`business` / `enterprise` automatically receive the right mailbox quotas\n+ feature flags via `defaultFeaturesForPlan()`; existing orgs get the\nbackfill via migration `0261_0262_mailbox_plan_features`.\n\n**21 new `PLAN_FEATURE_CATALOG` entries** in\n`modules/saas/src/lib/feature-catalog.ts`:\n\n- **3 module gates** — `mailbox.enabled`, `mailbox.shared_inboxes_enabled`,\n  `mailbox.compliance_pack_enabled` (Q13 — Enterprise only).\n- **5 AI sub-feature gates** — `mailbox.ai.enabled`, `model_tier` (Haiku /\n  Sonnet / Opus per tier), `ghostwriter_enabled`, `semantic_search_enabled`,\n  `agents_enabled`.\n- **3 account caps** — `max_personal_accounts` + `max_business_accounts`\n  (per-role maps with owner/admin/manager always unlimited; employee + client\n  gated), `max_shared_inboxes_per_org`.\n- **4 AI quotas** — summaries/drafts/searches/agents per-day-per-user\n  with `{soft, hard}` pairs (`-1` = unbounded by plan).\n- **2 storage caps** — body + attachment GB per user, `{soft, hard}`.\n- **2 sync flags** — `push_subscriptions_enabled` + `poll_interval_seconds_minimum`.\n- **2 send caps** — messages-per-hour-per-account + messages-per-day-per-account.\n\n**Tier ladder** anchored to Shortwave's pricing model (see\n`docs/plans/MAILBOX_PLAN_TIERS.md` §1 + §2 for the full rationale):\n\n- **Free** — Haiku model, 10 summaries/day, 5 drafts/day, 1 personal mailbox\n  for employees, no shared inboxes, 2 GB storage, 10-min poll floor.\n- **Starter** — Sonnet, 100 summaries, 50 drafts, 3 personal + 1 business\n  per employee, 1 shared inbox per org, push sync, 10 GB.\n- **Business** — Sonnet + Ghostwriter + Agents, 500 summaries, 200 drafts,\n  5+3 mailboxes per employee, unlimited shared inboxes, 50 GB.\n- **Enterprise** — Opus, unlimited AI + storage + mailboxes, Compliance\n  Pack enabled, 30 s poll floor.\n\n**Type system change:** `FeatureCatalogEntry.defaultValue` and `defaultsByPlan`\nnow accept a wider `FeatureValue` union (`boolean | number | string | null |\nReadonly<Record<string, unknown>>`) so `kind: 'json'` entries (per-role caps\nand soft/hard pairs) can carry structured defaults. Existing entries are\nuntouched — `boolean | number | null` are still all valid `FeatureValue`s.\n\nThe existing drift-guardrail test (`feature-catalog.test.ts > matches the\ncanonical per-tier defaults at the time the back-fill migration shipped`)\nis updated with the 21 mailbox keys per tier. Snapshot stays the source of\ntruth — any future tier-number change MUST update both this test AND the\nmatching migration.\n\nMigration `0261_0262_mailbox_plan_features.sql` is non-destructive: each\nUPDATE merges (`||`) mailbox keys into the existing `features` jsonb so\nnon-mailbox features on every plan row are preserved verbatim. Idempotent.\n\nThe `mailbox.*` keys are NOT yet consumed by any action — they're in\nposition for the policy helpers in commit #5 and the real actions in\nM1+ to compose against.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-plan-features.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"cc176484-7611-48c4-87ac-906012da9b55","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-policy-helpers","type":"added","scope":"mailbox","summary":"Mailbox policy helpers — three-scope ACL, plan-feature gate, quota counter primitive.","body":"M0 commit #5 from `docs/plans/MAILBOX_M0_DESIGN.md` §12. Ships the\nsubstantive policy helpers that every `mailbox.*` action will compose\nin M1+. No user-visible functionality yet — the helpers exist; the\nactions that use them land in M1+.\n\n**Three helpers:**\n\n- **`requireMailboxAccountAccess(action)`** — the §3.5.2 three-scope\n  ACL from `MAILBOX_ARCHITECTURE_DECISION.md` §9.1.\n  - `scope='personal'` → hard fence on the owner; impersonation\n    BLOCKED (`ctx.impersonatorUserId != null` → deny).\n  - `scope='business'` → hard fence on the primary operator;\n    impersonation ALLOWED (audit middleware records real actor id).\n  - `scope='shared'` → membership of the REAL actor (not the\n    impersonatee) — prevents impersonation from inheriting\n    shared-inbox memberships.\n\n- **`requirePlanFeature(key)`** — boolean plan-feature gate. Reads\n  the org's `saas_plans.features.<key>` via `@helios/saas`'s\n  `readFeature`. `false` on the plan → deny; absent → allow (so dev\n  workspaces without a plan stay usable).\n\n- **`requireMailboxQuota({ key, scope })`** — quota gate + counter\n  increment. Reads the `{ soft, hard }` pair from the plan, checks\n  the current counter for the matching window, denies on `>= hard`\n  (with `quota_exceeded:<key>`), increments + allows otherwise. Hard\n  cap of `-1` = unbounded.\n\n**Counter primitive** (`modules/mailbox/src/policies/counters.ts`):\n\n- `readCounter` + `incrementCounter` + `purgeOldCounters` operate on\n  `mailbox_quota_counters` rows.\n- Idempotent upsert via `INSERT ... ON CONFLICT DO UPDATE`. Atomic\n  under concurrent action calls.\n- Sparse scope tuples — partial-scope counters (e.g. per-org-only,\n  per-user-only, per-account-only) coexist in the same table.\n- 30-day retention cleanup helper for the worker cron in M0 commit #6.\n\n**Schema fix migration** `0262_0263_mailbox_quota_nulls_not_distinct.sql`\n— recreates `mailbox_quota_counters_scope_idx` with `NULLS NOT DISTINCT`\nso the upsert pattern works when scope columns (org_id / user_id /\naccount_id) are NULL on both sides. Postgres' default is to treat NULLs\nas distinct, which breaks the partial-scope upsert. Drizzle's\n`.uniqueIndex()` helper doesn't yet expose `.nullsNotDistinct()`, so\nthe migration is the source of truth (same pattern as the existing\n`recruitment_screening_questions_key_org_idx`).\n\n**Test coverage:** 26 PGlite-backed tests (in addition to the prior\n14 schema tests):\n\n- 7 scope-resolver tests\n- 7 counter-primitive tests (windowStart math, read, increment,\n  sparse-scope NULL handling, multi-window independence, purge)\n- 12 three-scope ACL tests covering every branch of the policy\n  resolver — personal/business/shared × {owner / other / impersonator\n  / non-member} matrices.\n\nThe Q15 invariant is asserted directly:\n`personal: HARD-DENIES the impersonator (Q15) — even when impersonating\nthe actual owner`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-policy-helpers.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"6e1cf456-a46f-487e-aca7-d9d42cec704d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-schema-foundations","type":"added","scope":"mailbox","summary":"New mailbox module schema — three-scope ownership tables with DB-enforced privacy invariants.","body":"Lays the schema foundations for the new `modules/mailbox/` module (per\n`docs/plans/MAILBOX_BUILD_PLAN.md`). No user-visible functionality yet —\nschema only. Permissions, policy helpers, plan-catalog entries, and the\nfirst real actions land in follow-up commits per\n`docs/plans/MAILBOX_M0_DESIGN.md` §12.\n\n**New tables:**\n- `mailbox_accounts` — one row per connected mail account; the load-bearing\n  `scope` column distinguishes `personal` (user-provisioned, admin-unreachable)\n  from `business` (org-provisioned or domain-attested, admin-impersonable) from\n  `shared` (org-owned team inbox with member roster).\n- `mailbox_account_members` — shared-inbox member roster with observer / agent\n  / admin roles and per-member notification prefs.\n- `mailbox_account_delegations` — reserved schema slot for the Q11 delegation\n  feature (no UI in v1).\n- `mailbox_quota_counters` — rolling counter rows for plan-quota enforcement\n  (Q6 primitive).\n\n**Privacy invariants** (`docs/plans/MAILBOX_ARCHITECTURE_DECISION.md` §3.5)\nenforced by four DB CHECK constraints on `mailbox_accounts`:\n\n1. Personal + business require `user_id`; shared has none.\n2. Personal has no `org_id` (roams with user across orgs); business + shared\n   are org-locked.\n3. The Compliance Pack legal-hold flag cannot be set on personal mailboxes —\n   the personal-scope privacy invariant is absolute.\n4. Business-only fields (`provisioned_by_user_id`, `domain_attested_at`,\n   `scope_locked_by_admin`) are NULL / false outside `scope='business'`.\n\n**Existing table amendment:** `organization_domains` gains an\n`is_business_mail_domain` boolean column (default false) that drives the\n`mailbox.account.classify_at_connect` auto-detect resolver. Admins opt-in\nper-domain from `/settings/domains` (UI in M2); existing verified domains\ndo not auto-upgrade.\n\nMigration `0260_0261_mailbox_foundations.sql` is non-destructive and\nidempotent — `IF NOT EXISTS` on every CREATE.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-schema-foundations.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"bf448f0a-156d-44eb-abb3-8c4ed67eb2d5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-sync-interface","type":"added","scope":"mailbox","summary":"Full MailboxProvider interface + provider error taxonomy — locks the M1 contract so Gmail / Graph / IMAP / JMAP impls can land in parallel.","body":"M1a from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1. Fleshes out the\n`MailboxProvider` interface (previously a stub from M0 commit #4) so\nthe four protocol-specific implementations can land in parallel\ncommits without re-opening the contract.\n\n**Interface methods:**\n\n- `bootstrap(account, opts)` — async-iterable initial-sync pass.\n  Streams `{ messages, nextWatermark }` pages so the worker can write\n  to DB without loading 100k+ messages into memory.\n- `syncIncremental(account, watermark)` — cheap delta pass. Returns\n  new messages + the new watermark + deleted-id list + label changes\n  + a `hint: 'fresh' | 'rebootstrap'` for when the watermark is stale\n  beyond the provider's gap window (Gmail historyId >7d, etc.).\n- `renewWatch(account)` — refresh the push subscription at 60-70% TTL.\n- `send(account, request)` — outbound dispatch with full attachment\n  + threading support.\n- `fetchAttachment(account, ref)` — lazy attachment fetch.\n- `testConnection(account)` — verify the connect works.\n\n**New types:**\n\n- `MailboxAccountBinding` — projection from `mailbox_accounts` the\n  sync engine actually needs (tokens are passed already-decrypted;\n  providers never see the AES-GCM envelope).\n- `RawMessage` — provider-neutral inbound envelope with full headers,\n  RFC-2392-aware inline attachment metadata, SPF/DKIM/DMARC verdicts,\n  reply-to / references / threading hints.\n- `SendRequest` with two attachment kinds — `inline` (base64 bytes,\n  for small attachments) and `storage` (object-storage handle, so big\n  attachments don't sit in memory).\n- `SyncResult` with deleted-message list + per-message label deltas\n  so the ingest path reconciles `mailbox_message_labels` rows.\n- `AttachmentMetadata` + `AttachmentRef` for lazy fetch.\n\n**Provider error taxonomy:**\n\n`ProviderError` class with 8 `ProviderErrorKind` values:\n`auth_expired | quota_exceeded | not_found | gone | network |\nprovider_failed | bad_request | unsupported`. Maps cleanly to\naction-layer Result errors so the sync engine can decide retry vs\nre-auth vs give-up. Carries `retryAfterMs` + `providerStatusCode`\nfor rate-limit handling.\n\n**Helpers:** `isProviderError(value)` + `coerceProviderError(cause,\ncontext, fallbackKind)` so provider impls don't re-implement the\nsame try/catch shape.\n\n**Test coverage:** 9 tests covering the error helpers (pass-through,\nwrapping, context-formatting, fallback-kind, non-Error values).\nType-level contract is verified by `pnpm --filter @helios/mailbox-sync\ntypecheck` — clean.\n\nNext: M1b — Gmail provider implementation (OAuth flow + `users.watch`\n+ Pub/Sub webhook handler + `history.list` incremental + paginated\n`messages.list` bootstrap).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-sync-interface.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"0bc6470d-5010-4097-bdf3-ae2254c95375","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-worker-stub","type":"added","scope":"mailbox","summary":"Wire mailbox module into worker boot — action registry + jobs registrar + daily counter cleanup cron.","body":"M0 commit #6 (final M0 commit) from `docs/plans/MAILBOX_M0_DESIGN.md` §12.\nHooks the mailbox module up to the worker boot sequence so:\n\n- `mailbox.account.classify_at_connect` and `mailbox.health.ping` land\n  in the global action registry via the side-effect import of\n  `@helios/mailbox-module/actions`.\n- `registerMailboxJobs({ db })` is called — currently an empty\n  registrar; subscribers + sync crons land in M3+.\n- `startMailboxCountersCleanupCron(...)` runs daily, dropping\n  `mailbox_quota_counters` rows whose `window_start` is older than 30\n  days. Keeps the partial-unique index tight under sustained\n  quota-counter traffic.\n\nThe cron mirrors the existing audit-log retention cron pattern\n(daily cadence; 30-min initial delay so the migration-replay tick at\nboot isn't competing with a delete sweep; aborts on\n`ac.signal.aborted`).\n\n**This closes M0 — all six commits from `MAILBOX_M0_DESIGN.md` §12 are\nnow landed:**\n\n1. `db(mailbox)` — schema with 4 CHECK constraints\n2. `auth(mailbox)` — 29 `mailbox:*` keys + blueprint integration\n3. `saas(mailbox)` — 21 feature-catalog entries + plan backfill\n4. `feat(mailbox)` — module scaffold + `classify_at_connect` action\n5. `feat(mailbox)` — `requireMailboxAccountAccess` / `requirePlanFeature`\n   / `requireMailboxQuota` policy helpers\n6. `worker(mailbox)` — this commit\n\nTotal M0 footprint: ~3,500 LOC, 40+ tests across schema / policies /\npermissions / plan-catalog, no user-visible functionality (intentional —\nM0 is the foundation; M1+ ships the real product).\n\nNext phase: M1 — provider adapters (Gmail + Microsoft Graph + IMAP +\nJMAP), starting with Gmail-first MVP per `MAILBOX_BUILD_PLAN.md` §3.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-mailbox-worker-stub.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"212083f0-6ac3-467d-960a-7a19fb052b4c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-admin-views","type":"added","scope":"roadmap","summary":"Added By-category + Timeline views to /saas/roadmap and widened the kanban tab to all 6 lifecycle states.","body":"User-requested: more views on the admin roadmap surface. Previously\nthe admin had only 3 tabs (Triage, Roadmap kanban, Settings) and the\nkanban surfaced just Planned / In Progress / Shipped. Now there are 5\ntabs, and the Roadmap kanban surfaces every lifecycle status.\n\n**Roadmap kanban — widened to 6 columns.**\n\nOpen / Under review / Planned / In Progress / Shipped / Declined,\nmatching the public surface. Each column carries a status-tinted\ndot indicator + count badge. Loads every active feature (not just\n`roadmap_visible = true`) — admins want full visibility on triage\nqueues too. The \"On roadmap\" green badge per card flags items\ncurrently rendered on the public kanban so admins can spot the\ncurated subset at a glance.\n\n**New: By-category tab.**\n\nGroups every active feature by category (CRM / HRM / Sales /\nProjects / Chat / AI / Email / Recruitment / Platform / Design /\nOther). Cards rendered with the same vote-tile + on-roadmap chrome\nas the kanban. Categories with zero items are hidden. Each category\ngroup caps at the top 12 items with a \"+ N more\" hint linking back\nto Triage for the full list. Useful for routing triage to the right\nmodule owner — \"what's in CRM's queue this week?\".\n\n**New: Timeline tab.**\n\nGroups features by their `target_label` (free-text quarter / \"Soon\" /\n\"Later\"). Within each bucket, ordered by status (Planned → In\nProgress → Shipped → others) then vote count desc. Labelled buckets\nsorted alphabetically; \"No target yet\" pinned to the bottom.\nSurfaces \"what are we committing to for 2026 Q3?\" without admins\nhaving to filter Triage by label.\n\n**Shared `KanbanCard` component.**\n\nRefactored the inline card markup from the old kanban into a single\n`KanbanCard` consumed by the kanban, By-category, and Timeline tabs.\nVote tile uses `ArrowFatUp` + count (matching the public surface)\ninstead of `Sparkle`. Removed the `Sparkle` import.\n\n**Note on data freshness.**\n\nAll three new views share the same `platform.roadmap.feature.list_admin`\nendpoint with a `limit: 500` cap. For deployments past 500 active\nfeatures this needs pagination + virtualization — queued as a future\npolish item; current product is in the early data regime.\n\nTypechecks clean. Tests unchanged (no schema changes).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-roadmap-admin-views.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"9e63a9f4-3676-4083-a7cd-12f5a0988375","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-core-actions","type":"added","scope":"storage","summary":"Added the four core storage object actions — put_url, confirm_upload, get_url, delete.","body":"Phase 2E of the unified Storage + Drive plan. Four registered actions\non the `@helios/storage-module/actions` surface:\n\n- `storage.object.put_url` — mint a presigned PUT URL + stable\n  objectId. Quota gate (total bytes + addons + per-purpose sub-caps +\n  per-file size + file count) fires HERE so denied uploads never\n  consume a presign roundtrip. Inserts a `storage_objects` row in\n  state `scanState='uploading'` (no meter event yet — that's confirm).\n  Emits `storage.object.upload_requested`.\n- `storage.object.confirm_upload` — caller tells the server bytes\n  landed. Writes the meter event (`uploaded`, +sizeBytes, +1 file)\n  via the sharded counter writer with idempotency key\n  `upload.<orgId>.<objectId>`. Flips scanState to 'clean' (Phase 8\n  will set 'pending' instead and trigger AV). Idempotent re-call\n  returns current state without double-counting.\n- `storage.object.get_url` — mint a presigned GET URL, deny when\n  scanState is not 'clean' or the row is soft-deleted, record an\n  egress meter event. Default TTL 600s, max 7 days.\n- `storage.object.delete` — soft-delete (mark `deletedAt`), record a\n  negative meter event (-sizeBytes, -1 file). Provider DELETE happens\n  later via the retention sweep. Idempotent re-call returns existing\n  deletedAt. `dangerous: true` (the AI runtime confirms before\n  invoking).\n\nOrg-scope is enforced on every action: cross-tenant writes / reads /\ndeletes require `platform:storage:usage:read`. Per-object access\ncontrol is delegated to producing modules via the policy seam.\n\n22 schema-validation vitest cases (validation_failed paths). DB-side\nintegration tests land alongside Phase 2F's PGlite harness work.\n\nThe driver client is bootstrapped from `process.env` for Phase 2E;\nper-profile credential decryption (KMS-wrapped `config` on\n`storage_profiles`) lands in Phase 3. The `profile_id` is correctly\nrecorded on every row so the migration to per-profile creds is a\nzero-data-touch change.\n\nSee docs/plans/UNIFIED_STORAGE_AND_DRIVE/03_STORAGE_MODULE_SPEC.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:39.368Z","updatedAt":"2026-06-16T00:03:39.368Z"},{"id":"fa284f31-d510-47ea-9da6-2c58a53f9e0a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-attach-external-tracker","type":"added","scope":"roadmap","summary":"Added feature.attach_external_tracker — admin link to Linear / GitHub / Jira / ClickUp issues.","body":"Phase 6c first step. Root-only action that links a roadmap feature to\nan external issue-tracker URL (Linear / GitHub / Jira / ClickUp).\nStamps `external_tracker_synced_at` on attach so the admin sheet can\nshow \"last synced X ago\".\n\nThe full bidirectional Linear webhook receiver (status changes flowing\ninbound from Linear → Helios feature.set_status) is queued as a\nfollow-up commit; this commit ships the operator-side connection so\nadmins can start logging tracker URLs against features.\n\nSchema additions to `modules/roadmap/src/schemas/index.ts`:\n\n- `AttachExternalTrackerInput` — id + externalTrackerKind +\n  externalTrackerUrl. Refines on `kind != 'none' → url required`.\n- `AttachExternalTrackerOutput` — returns the updated `FeatureAdminDto`.\n\nPass `externalTrackerKind='none'` to detach. On detach, both the URL\nand the synced-at timestamp clear.\n\nEmits the standard `featureUpdated` event with\n`changedFields: ['externalTrackerKind', 'externalTrackerUrl']` so\nexisting subscribers see it like any other metadata edit.\n\nRegistered in `modules/roadmap/src/actions/index.ts`. 60 / 60 module\ntests still pass. Typechecks clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-roadmap-attach-external-tracker.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"4e86e91a-a49b-4bc6-8c2b-2feaa59805e1","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-dedup-v2","type":"changed","scope":"roadmap","summary":"Roadmap submit: 3-tier dedup with category boost, recent-by-user list, and in-card upvote.","body":"The \"Request a feature\" form now detects related requests across three confidence tiers — _very likely the same_, _likely the same_, and _possibly related_ — sorted with shipped items first, then in-progress, planned, open, under-review, and declined. Each match shows a status pill and an \"Upvote this instead\" button that records the vote and skips submission. A separate \"You recently submitted\" panel reminds the requester of their own last week of activity.\n\nBehind the scenes, the embedding text now includes title + summary + use-case + the first 2 KB of the description; matching against the same category gets a small confidence boost; and the safety net before insert only triggers a duplicate prompt for the upper two tiers — possibly-related items inform but never block.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-roadmap-dedup-v2.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"12833c44-e6bd-41fa-8d31-8581628c163c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-feeds-and-widget","type":"added","scope":"web","summary":"Added /api/roadmap{.json,.rss} feeds + /widget/v1/roadmap-pill.js embed for partner sites.","body":"Phase 7. Three new public endpoints making the roadmap reachable from\noutside the in-app surface.\n\n**`GET /api/roadmap` + `GET /api/roadmap.json`** — JSON feed of the\nfull kanban shape from `platform.roadmap.board.summary_public`. Six\nstatus buckets + hero copy + `publicReadEnabled` flag. Cached\n`max-age=60, stale-while-revalidate=600`. CORS-permissive\n(`access-control-allow-origin: *`) so AI agents and partner sites can\nconsume directly. Mirrors the consumption shape used by\n`apps/marketing/src/pages/roadmap.astro`.\n\n**`GET /api/roadmap.rss`** — RSS 2.0 feed of shipped + planned +\nin-progress items (newest by `updatedAt`, top 30). Each item carries\nthe title prefixed with a state badge (`[Shipped]`, `[In progress]`,\n`[Planned]`), category, link to `/help/roadmap/<slug>`. RSS readers +\nZapier integrations can subscribe. Skips Open / Under review /\nDeclined — admin signal, not subscriber-actionable. Per-status guid\n(`<id>-<status>`) so a feature progressing through multiple states\ngenerates a separate feed entry per state.\n\n**`GET /widget/v1/roadmap-pill.js`** — self-contained embeddable\nscript (~1 KB, zero deps, no tracking). Partner docs + community\nsites drop:\n\n\\`\\`\\`html\n<script async src=\"https://api.example.com/widget/v1/roadmap-pill.js\"\n        data-target=\"#roadmap-pill\"></script>\n<div id=\"roadmap-pill\"></div>\n\\`\\`\\`\n\nThe script reads its own `src` URL to derive the API origin, fetches\n`/api/roadmap.json`, picks the newest item from shipped + in-progress\n+ planned, and renders a single pill: status badge + title, linking\nback to `/help/roadmap/<slug>` in a new tab. Inline styles only (no\nexternal CSS), system font stack, semi-transparent neutral background\nso it inherits parent theme. Silent on error — no UI broken if the\nfeed is unreachable.\n\nImplementation in `apps/web/src/server/platform-roadmap-feeds.ts`,\nmirroring `platform-changelog-feeds.ts` shape exactly. Wired into\n`prod.ts` URL routing alongside the existing changelog + status\nfeed paths.\n\nTypechecks clean. No schema or action changes — pure read surface on\ntop of the existing `board.summary_public` action.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-roadmap-feeds-and-widget.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"cea8b4db-50ea-4937-bc29-7a7331c8e0f3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-vote-on-behalf","type":"added","scope":"roadmap","summary":"Added vote.cast_on_behalf — sales/CSM workflow for capturing verbal customer votes from calls/QBRs.","body":"Phase 6b. Sales / CSM teams capture verbal demand from customer calls\nall the time; previously that signal lived in Slack DMs and disappeared.\nNow it lands directly on the roadmap with full audit trail.\n\n**Two new actions** (both root-only, `platform:roadmap:manage`):\n\n- `platform.roadmap.vote.cast_on_behalf` — records a vote attributed to\n  a real customer user with a required free-text note capturing the\n  conversation context (e.g. \"CEO of Acme Corp asked for this on the\n  2026-06-14 QBR — blocking 2x export contract\"). `dangerous: true`.\n  Idempotent: if the customer already has a vote, the existing note is\n  preserved (no silent history rewrite). Auto-follows the customer on\n  first vote so they receive shipped-state emails — same semantics as\n  the normal `vote.toggle`. Emits `platform.roadmap.feature.voted` with\n  `castOnBehalfBy` populated so downstream subscribers can distinguish\n  first-party votes from sales-captured demand.\n\n- `platform.roadmap.vote.list_on_behalf_admin` — returns the captured-\n  vote feed for a feature (rows where `cast_on_behalf_by IS NOT NULL`).\n  Each row carries the target user name + email, the admin who captured\n  it, and the note. Read-only, root-only.\n\n**Event extension.**\n\n`platform.roadmap.feature.voted` now carries an optional `castOnBehalfBy`\nfield — null/absent for first-party votes, set to the admin's user id\non captured votes. Backward-compatible for existing subscribers.\n\n**Anti-foot-gun guard.**\n\nThe action rejects casts where `userId === actor.id` — admins should\nuse the normal `vote.toggle` for their own opinion. Casting on behalf\nof yourself would silently rewrite history.\n\n**Admin UI** on `/saas/roadmap` triage edit sheet:\n\nNew \"Capture customer vote\" block above the existing Merge block.\nFree-text user UUID input (typed-ahead picker queued for Phase 8) +\nrequired note textarea. Below the form, lists existing captured demand\nfor that feature — name, note, capture date, and which admin captured\nit. Lets the admin see \"who's already on record asking for this\" at a\nglance during triage.\n\n**Tests:** 3 new vitest cases (policy denial / empty-note validation /\nself-cast rejection). Happy-path + idempotent-vs-existing-vote lives\nin the PGlite integration suite — the fakeDb's same-rows-for-every-\nselect pattern can't distinguish the four select calls the handler\nmakes.\n\nTotal module tests: 60/60 pass. Typechecks clean across\n`@helios/roadmap` and `apps/web`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-15-roadmap-vote-on-behalf.md","internalOnly":false,"createdAt":"2026-06-15T15:59:12.910Z","updatedAt":"2026-06-15T15:59:12.910Z"},{"id":"373d0281-aacc-4fc1-a6ba-2e8edc9e2386","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-sla-pause-accounting","type":"fixed","scope":"support","summary":"SLA timers no longer burn budget while a ticket sits in a paused status.","body":"When an SLA policy marks certain statuses as paused (e.g. \"waiting on\ncustomer\"), the ticket's first-response / next-response / resolution due-ats\nare now pushed forward by the business-time the ticket actually spent paused,\nreconstructed from the status-change audit trail. Previously the due-ats were\ncomputed straight from creation time, so a ticket parked in a paused status\nfor days could breach the instant it came back — even though the clock should\nhave been frozen. Tickets that were never paused are completely unaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.389Z","updatedAt":"2026-06-15T22:33:59.389Z"},{"id":"5bbab005-1812-4fe1-9919-7ccee4c3b225","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-registry-fix-and-drafts","type":"fixed","scope":"mailbox","summary":"Fix /mail 404s by registering mailbox actions at import time + auto-save compose drafts to localStorage.","body":"Two-part commit:\n\n## Critical: action registry boot\n\nThe mailbox action catalog was never actually registered in the\nweb app's runtime. Two compounding causes:\n\n1. `apps/web/src/server/api.ts` imports every other module's\n   actions for side effects (`import '@helios/crm/actions';`)\n   but the mailbox-module import was missing.\n2. Even if added, `modules/mailbox/src/actions/index.ts` wrapped\n   the `registerAction()` calls in a `registerMailboxActions()`\n   function that nothing called.\n\nNet effect: every mailbox UI action call (`mailbox.account.list_own`,\n`mailbox.shared.account.list`, `mailbox.account.connect`,\n`mailbox.thread.list`, …) returned 404 in production. The whole\n`/mail` page was dead.\n\nFix:\n- Moved the `registerAction(...)` calls in\n  `modules/mailbox/src/actions/index.ts` to the top level (the\n  pattern every other module uses — see\n  `modules/crm/src/actions/index.ts`).\n- Added `import '@helios/mailbox-module/actions';` to\n  `apps/web/src/server/api.ts`.\n- Kept `registerMailboxActions()` as a no-op export so any\n  existing caller in the worker boot still type-checks.\n\n## Drafts auto-save to localStorage\n\nCloses a critical compose UX gap — accidentally closing the\ncompose dock or refreshing the page used to lose everything you'd\ntyped.\n\nBehaviour:\n- The compose dock auto-saves every 600ms of inactivity to\n  `localStorage` under\n  `helios:mailbox:draft:<accountId>:<threadId|'new'>`.\n- On reopen, the dock checks for a saved draft and prefills it,\n  but **only when the incoming compose context has no content**.\n  This keeps AI-generated drafts (which seed the subject + body)\n  from being clobbered by a stale local draft.\n- A `Draft auto-saved · {timeAgo}` line appears in the dock\n  footer once the first save happens.\n- A `Discard` button (next to Send) wipes the draft + closes the\n  dock.\n- A successful Send always clears the draft.\n\nlocalStorage (not server) for v1 because shipping a\n`mailbox_drafts` table needs a coordinated migration that\nmultiple parallel sessions can't safely add at the same time. The\nstorage key is forward-compat: a future server-side\n`mailbox.draft.save` action can backfill from any browser's\nlocalStorage on next sign-in.\n\n## Net behavior\n\n- `/mail` actually works in production again.\n- Type a reply → close the tab → reopen → reply is still there,\n  with a footer hint showing when it was saved.\n\nTests: 248 mailbox-module tests still pass (no test changes —\nthe registry fix is import-time + the drafts are pure\nclient-side).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.415Z","updatedAt":"2026-06-15T22:33:59.415Z"},{"id":"25a87553-0ed1-4b21-b209-99f56c629e5b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-bulk-operations","type":"added","scope":"mailbox","summary":"Multi-select threads and bulk-archive, snooze, star, mark read, or trash — keyboard shortcuts X and ⌘A included.","body":"Real-inbox triage requires multi-select. This commit lands the\nbackend action + the UI affordances:\n\n## Actions\n\nTwo new actions, one per scope:\n\n- **`mailbox.thread.bulk_mutate({threadIds, action, until?})`** —\n  takes up to 500 personal/business thread ids + a single verb\n  from `mark_read | mark_unread | star | unstar | archive |\n  unarchive | snooze | unsnooze | trash`. `until` is required +\n  validated when `action='snooze'` (future timestamps only,\n  365-day ceiling).\n- **`mailbox.shared.thread.bulk_mutate(...)`** — same shape, but\n  ownership runs through `loadSharedAccess`. Observers receive\n  `policy_denied` per row.\n\nBoth return `{successCount, errorCount, results: [{threadId, ok,\nerrorCode}]}`. Partial failures don't abort the batch — a\nforeign thread id in the middle of an alice batch just records\n`policy_denied` for that row; the rest succeed.\n\nEach row is verified independently (load thread → load account\n→ ownership check) so a hostile caller can't inject other\nusers' thread ids into the array.\n\nGated by `mailbox:thread:write:own` and\n`mailbox:thread:write:shared` respectively.\n\n## UI\n\n### Selection state\n- `selectedThreadIds: Set<string>` lives at the page level.\n- Auto-resets when switching accounts, folders, or running a\n  new search.\n\n### Row checkbox\n- Each thread row gets a 16px checkbox that's invisible until\n  hover (or when the row is selected). Selected rows highlight\n  with the same accent the active thread uses.\n- Click the checkbox to toggle; clicking the row body still\n  opens the reader.\n\n### Bulk toolbar\n- Appears above the thread list whenever `selectedCount > 0`.\n- Shows `N selected` + `Select all N` (when more threads are\n  visible than currently selected) + `Clear`.\n- Action buttons for Mark read, Mark unread, Star, Snooze\n  (with preset popover same as single-thread snooze), Archive,\n  Trash.\n- All buttons disabled when the actor is a shared-inbox\n  observer (server-side `policy_denied` is still the source of\n  truth).\n\n### Keyboard\n- **`X`** — toggle selection on the active thread (Gmail/Linear\n  parity).\n- **`⌘A` / `Ctrl+A`** — select every visible thread.\n- **`Esc`** — clear the selection (when at least one is\n  selected; otherwise still closes the reader).\n\nAfter a successful bulk action a toast summarises:\n`\"3 archived\"` or `\"3 archived, 1 failed\"` when partial.\n\n## Test coverage (+12, 287 total mailbox-module tests)\n\n`thread-bulk-mutate.test.ts`:\n- archive across 3 threads in one call\n- mixed-success: alice's batch with one of bob's thread ids\n  → bob's row records `policy_denied`, alice's succeed\n- mark_read flips thread + message rows\n- snooze sets `snooze_until` on every batch row\n- snooze validation: missing `until`, past `until`, ceiling\n- trash sets `deleted_at`\n- not_found per row for unknown ids\n- policy denial\n- shared agent can archive; observer + non-member receive\n  per-row `policy_denied`\n\n## Net\n\nCustomer-support triage (the use-case where this actually\nmatters) gets the Gmail/Linear-level bulk-edit experience:\nselect 20 newsletters → Archive → empty inbox in two\nclicks.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:06.929Z","updatedAt":"2026-06-16T04:14:06.929Z"},{"id":"c63a0dc1-21a3-4b65-8c30-0a1a4c9da509","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-oauth-callback-route","type":"added","scope":"mailbox","summary":"HTTP route /api/mailbox/oauth-callback/{provider} that receives the OAuth redirect and invokes mailbox.account.connect.complete.","body":"M2-c from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Lands the\nHTTP-protocol adapter that closes the connect dance from the\nbrowser's perspective. After this commit, the wire-up between the\nprovider's OAuth screen and our DB write is complete — a user\nclicking \"Connect Gmail\" in the (forthcoming) `/settings/mailbox`\nUI will return to a real `mailbox_accounts` row in the DB.\n\n**`apps/web/src/server/mailbox-oauth-callback.ts`** —\n`handleMailboxOAuthCallback(req, res)`:\n\n- Matches `/api/mailbox/oauth-callback/{gmail|outlook_oauth}` —\n  per-provider path so the handler routes to the matching impl\n  without parsing the state token first.\n- GET-only; rejects other methods with 405.\n- Parses `code` + `state` from the query string. Returns a friendly\n  error page when either is missing.\n- Detects the user-denied-consent case (`?error=access_denied`\n  from the provider) and renders an error page with a \"Try again\"\n  button back to `/settings/mailbox`.\n- Builds an `ActionContext` via the existing\n  `createRequestContext(req, requestId)` helper — same path the\n  normal action API uses, so the actor / permissions / impersonator\n  / org all resolve correctly.\n- Invokes `mailbox.account.connect.complete({code, state})` action.\n- On success: renders a small HTML page showing\n  \"Connected Google · Personal mailbox · alice@gmail.com\" with a\n  1.5s meta-refresh to the `returnTo` URL the action returned.\n- On failure: renders an error page with the action's `code` +\n  `message` and a \"Try again\" link to `/settings/mailbox`.\n- Logs unexpected throws via `@helios/config/logger` so operators\n  can correlate OAuth flow failures.\n\nHTML rendering is inline (no template engine) and follows the\n`@helios/email`'s `email-oauth-bootstrap.ts` pattern. Minimal\nCSS, system fonts, light-and-dark theme via `prefers-color-scheme`,\nHTML-escaped for every user-supplied + provider-supplied string.\n\nThe route is registered in `apps/web/src/server/prod.ts` next to\nthe existing `handleEmailOauthBootstrap`. `@helios/mailbox-module`\nadded to `apps/web/package.json` deps.\n\nTypecheck across `apps/web` is clean.\n\n**What's still missing for end-to-end dogfooding:**\n\n1. The `/settings/mailbox` UI page — lists connected accounts with\n   their scope + status, exposes \"Connect Gmail\" / \"Connect Outlook\"\n   buttons that call `mailbox.account.connect` and redirect the\n   browser to the returned `authUrl`.\n2. `mailbox.account.list_own` action — the UI list query.\n3. `mailbox.account.disconnect:own` action — the row-removal path.\n\nThose land in M2-d. After that the connect flow is fully usable\nwith a real Gmail / Microsoft 365 account against a deployed\noperator-configured OAuth client.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:15.741Z","updatedAt":"2026-06-15T17:00:15.741Z"},{"id":"ff11bc44-50b5-4dcb-ba77-527f7226e53a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-ephemeral-compose","type":"added","scope":"chat","summary":"New composer toolbar affordance — pick recipients, write a message, send it as a per-user ephemeral that only they see in the channel.","body":"Completes the ephemeral-messages UX. The receive side (`EphemeralPanel`)\nshipped in [`c2125624`](commit/c2125624); this is the send side.\n\nA new `EyeSlash` glyph in the channel composer's toolbar (next to the\nexisting Paperclip / Mic / Poll / Link / SmartCompose buttons) opens a\npopover with:\n\n- **Recipient typeahead** — bound to `chat.user.search` with the active\n  `channelId`, so the DM-hardening pass's \"channel-members only\" filter\n  applies for free. Picked names render as removable chips; Backspace in\n  an empty input pops the last chip; Enter accepts the first\n  suggestion.\n- **Plain-text body** — Cmd/Ctrl+Enter sends. 2000-char cap to match\n  the schema.\n- **TTL segmented control** — 10 min default / 1 hour / 24 hours\n  (the three real-world buckets from the spec).\n\nSend → `chat.message.post_ephemeral`. On success a confirmation toast\nthat handles singular/plural (\"Ephemeral sent to {name}.\" vs\n\"Ephemeral sent to {count} people.\"). On failure: a localized toast and\nthe popover stays open so the user can adjust + retry.\n\nUX choice: popover instead of a Modal so the user can glance back at\nthe conversation context while composing — sending a private aside in\nthe middle of a channel discussion is the most common use case. The\n\"Send privately\" CTA carries the module-chat gradient + the disclaimer\nstrip (\"Only the people above will see this; nothing is posted to the\nchannel.\") so there's zero ambiguity about who reads it.\n\nThe receive-side `EphemeralPanel` will pick the new row up on its next\n30s poll; future commits can fan the new ephemeral out via realtime\nfor a snappier round-trip.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.091Z","updatedAt":"2026-06-15T19:59:57.091Z"},{"id":"57bcfa70-1363-4f93-b31d-3149ce7e459f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"recruitment-offer-reads-storage-get-url","type":"changed","scope":"recruitment","summary":"Recruitment offer read paths prefer storage.object.get_url; legacy direct presign is now the fallback.","body":"Phase 6.2.1 of the unified Storage + Drive plan. Unblocked by\nthe Phase 6.4.3 `pdf_storage_object_id` column on\n`recruitment_offers` (commit `88cd0757`) + the 6.4.5-rec wiring\n(`83484dca`) that populates it on every offer send.\n\nThree read paths now prefer `storage.object.get_url` when the\nFK is populated, falling back to the legacy direct\n`presignDownload` for offers pre-dating the 6.4.3 column:\n\n1. **`offer-public.ts` (`tryPresignOfferPdf`)** — the\n   public-token candidate preview. Already had a sysCtx for\n   render-on-demand; now also uses sysCtx with\n   `storage:object:read:own` for the read presign. The\n   render-on-demand path captures `result.storageObjectId`\n   and persists it onto `pdfStorageObjectId` so subsequent\n   reads skip the render.\n\n2. **`offer-share-pdf.ts`** — the operator-side share link\n   builder. Same shape: select `pdfStorageObjectId`, prefer\n   `storage.object.get_url`, fall back to direct presign.\n\n3. **Subscriber attachment shape** —\n   `email-on-offer-events.ts`'s local `DispatchArgs` type now\n   declares the `storageObjectId?: string` field that\n   `83484dca` started passing through. Pre-existing typecheck\n   gap closed.\n\nWhat the migrated read path gains:\n- the per-purpose **access registry hook** fires (defense in\n  depth on top of the token verification the action already\n  did)\n- the **egress meter event** lands → per-tenant accounting on\n  `/saas/storage`\n- `scanState !== 'clean'` is enforced — Phase 8 AV gating is\n  automatic once it ships\n\nThe public-token actor uses a system context with\n`storage:object:read:own`. The token verification above\nestablishes access; the sysCtx bridges into storage's\npermission model.\n\nAll 188 recruitment tests pass; the pre-existing\nimplicit-any-in-tests typecheck noise on `status-sync.test.ts`\nis unrelated.\n\nCloses 6.2.1 from `12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T05:34:46.256Z","updatedAt":"2026-06-20T05:34:46.256Z"},{"id":"9e846cf3-c045-4db3-b0d4-07c8f9285a3b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-linear-webhook-rate-limit-after-verify","type":"security","scope":"roadmap","summary":"Linear webhook rate-limit now only consumes after signature verification — bogus traffic can't drain the operator's quota.","body":"The Linear webhook receiver previously consumed its per-IP rate-limit bucket on every request, then checked the HMAC signature. An attacker with no signing secret could exhaust the operator's legitimate quota by sending bogus payloads at 600/min. The new flow has a loose pre-verify shed (6000/min, only sized to stop L7 floods from racking up database lookups) and a tight post-verify bucket (600/min) that only drains for signed-and-verified events.\n\nAudit finding **A3** from `docs/plans/PLATFORM_ROADMAP_MODULE_AUDIT_AND_IMPROVEMENT_PLAN.md` — closed. Also fixed audit finding **A13** by importing the canonical `SYSTEM_USER_ID` from `@helios/db` instead of duplicating the UUID literal.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.132Z","updatedAt":"2026-06-15T19:59:57.132Z"},{"id":"3de33662-9f17-4dc2-af69-19477ae33dff","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-provider-roundtrip","type":"added","scope":"mailbox","summary":"Local mark-read/star/archive/trash flips now push back to Gmail and Microsoft Graph so Helios and the source mailbox stay in sync.","body":"Until now, marking a thread read or archived in Helios only\nupdated Helios's local DB — Gmail and Outlook still showed it\nunread / in inbox. The two views diverged the moment the user\ntouched the mailbox. This commit closes the loop.\n\n## Provider interface\n\n`MailboxProvider` gains an optional `applyLabelChange()`\nmethod. New `ProviderLabelChange` discriminated union:\n\n- `{kind: 'mark_read', providerMessageIds, unread}`\n- `{kind: 'star', providerMessageIds, starred}`\n- `{kind: 'archive', providerMessageIds, archived}`\n- `{kind: 'trash', providerMessageIds}`\n\n## Gmail implementation\n\n- mark_read / star / archive → POST\n  `/users/me/messages/batchModify` with `addLabelIds` /\n  `removeLabelIds`. Chunked at 500 ids/call.\n- Label mapping: `UNREAD`, `STARRED`, `INBOX` (remove =\n  archived).\n- trash → POST `/users/me/messages/{id}/trash` per message\n  (Gmail's trash endpoint isn't batched).\n\n## Microsoft Graph implementation\n\n- mark_read → PATCH `/me/messages/{id}` with `{isRead}`.\n- star → PATCH `/me/messages/{id}` with\n  `{flag: {flagStatus: 'flagged' | 'notFlagged'}}`.\n- archive → POST `/me/messages/{id}/move` to the\n  `archive`/`inbox` well-known folder.\n- trash → POST `/me/messages/{id}/move` to `deleteditems`.\n\nPer-message PATCH (no batch endpoint with the same shape as\nGmail's batchModify). The 500-id cap on bulk_mutate keeps\nworst-case calls bounded.\n\n## Action wiring\n\nNew helper `lib/push-label-change.ts`:\n- Loads the account binding + provider from the runtime.\n- Pulls per-thread `providerMessageId` lists in one query.\n- Calls `provider.applyLabelChange()` per thread.\n- **Best-effort**: errors are logged via `ctx.logger.error` and\n  swallowed. The local change always wins; the next\n  incremental sync reconciles divergence.\n- Skips `status !== 'active'` accounts (no point burning\n  quota on a known-broken connection).\n- Skips providers that don't implement `applyLabelChange`\n  (IMAP/JMAP fallback path — sync will reconcile).\n\nWired into `mailbox.thread.{mark_read, star, archive}` — each\ncalls `pushLabelChange` after the local update succeeds.\n\n## What's NOT in this commit\n\n- Trash push is plumbed end-to-end (provider impls done) but\n  no `mailbox.thread.trash` single-thread action exists yet\n  — the user trashes via `bulk_mutate` (which lands in a\n  follow-up wiring commit).\n- Shared-inbox mutation actions don't push yet. Shared\n  inboxes are typically managed by team admins through the\n  provider's own UI anyway; the personal/business path is\n  the higher-value gap.\n- Bulk_mutate doesn't push yet — same follow-up.\n\n## Net\n\nMark a thread read in Helios → it shows read in Gmail/Outlook\nwithin seconds. Archive → moves out of provider's inbox. The\ndivergence problem is closed for personal + business mailboxes\non the single-thread mutation path.\n\n287 mailbox-module tests still pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.159Z","updatedAt":"2026-06-16T04:14:07.159Z"},{"id":"0446cff3-37b9-4cac-b422-48b59a31e392","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-ai-bot-cost-tracking","type":"fixed","scope":"support","summary":"Support AI-bot replies now record their estimated cost so per-agent budgets work.","body":"Each AI-bot reply now records an estimated cost (token counts × the shared\nmodel price catalog) on the conversation instead of always logging $0. A\nsupport AI agent's `monthlyBudgetCents` cap can now actually trip — previously\nthe month-to-date total summed to zero, so the budget never engaged. The\nrouting-layer cost ledger remains the canonical spend record; this is the\nper-agent budget's own running tally.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.637Z","updatedAt":"2026-06-15T22:33:59.637Z"},{"id":"aa79e772-5017-4dca-b958-0b427a15e76a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-graph-client-bootstrap","type":"added","scope":"mailbox","summary":"Microsoft Graph HTTP client + provider scaffold — testConnection + bootstrap end-to-end.","body":"M1f-next from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1. Ships the\nHTTP-bound half of the Graph provider — the client wrapper with full\nauth-refresh / retry / backoff plus the two methods that exercise it\n(`testConnection`, `bootstrap`). `syncIncremental`, `renewWatch`,\n`send`, `fetchAttachment` still throw `ProviderError({kind:'unsupported'})`\n— each lands in the follow-up commits, same pattern as Gmail's\nM1c → M1d → M1e progression.\n\n**`packages/mailbox-sync/src/providers/graph/client.ts`** — `GraphClient`\nwith `request({binding, method, path, query?, body?, prefer?, onTokenRefreshed?})`:\n\n- Same flake-handling shape as the Gmail client (proactive refresh,\n  401-then-retry, 429-Retry-After, 5xx-exponential-backoff,\n  network-error coercion) — Microsoft's status-code semantics overlap\n  Google's enough that the same logic works.\n- **Refresh token ROTATION handling.** Microsoft rotates the refresh\n  token on every refresh; the client tracks the latest one\n  in-memory across retries AND surfaces it to the caller via\n  `onTokenRefreshed` so they can persist the new value to\n  `mailbox_accounts`. The Gmail provider doesn't need this (Google\n  doesn't rotate).\n- **`@odata.nextLink` handling.** Graph returns the next-page URL as\n  a full URL, not a token. The client recognises HTTP-prefixed paths\n  and passes them through unchanged.\n- **Tenant id propagation.** Default `tenant='common'` for\n  multi-tenant apps; single-tenant apps pass the specific tenant id.\n- **Prefer header support.** Graph honours\n  `Prefer: outlook.body-content-type=\"html\"` for Message reads;\n  per-request opt-in.\n- **410 → `gone`** (Graph's stale-delta-link signal, analog of\n  Gmail's 410 stale historyId — `syncIncremental` in the next commit\n  catches this and triggers rebootstrap).\n\n**`packages/mailbox-sync/src/providers/graph/provider.ts`** —\n`GraphProvider` implementing `MailboxProvider`. This commit:\n\n- `testConnection(account)` — GET `/me`. Picks email from `mail` (the\n  primary SMTP address) with fall-back to `userPrincipalName`\n  (Microsoft 365 sign-in name; sometimes `*.onmicrosoft.com`).\n- `bootstrap(account, opts?)` — paginated walk of `/me/messages`:\n  - First probes `/me/messages/delta?$top=1&$select=id` to capture\n    the **initial `@odata.deltaLink`** as the watermark. Subsequent\n    `syncIncremental` resumes from this cursor. Best-effort — if the\n    probe fails the bootstrap still ships messages with an empty\n    watermark (forces a recovery rebootstrap on first sync, same as\n    Gmail's empty-watermark path).\n  - Walks `/me/messages` with `$top=N&$select=<envelope-fields>&$expand=attachments(...)`\n    so attachment metadata arrives inline (avoids the per-message\n    `$expand` round-trip Gmail's separate `messages.get` pattern\n    would require).\n  - Optional `opts.sinceDays` adds `$filter=receivedDateTime ge <iso>`\n    for the M4 recovery rebootstrap path.\n  - Yields `{messages, nextWatermark}` per page; the watermark is\n    constant across pages.\n\n`syncIncremental` / `renewWatch` / `send` / `fetchAttachment` —\nreject with `ProviderError({kind:'unsupported'})` until the follow-up\ncommits.\n\n**Test coverage:** 12 new Graph provider tests (142 total in\nmailbox-sync now):\n- testConnection metadata + mail-vs-UPN fall-back + auth header\n- bootstrap delta-probe + list pagination + sinceDays filter +\n  RawMessage translation\n- bootstrap continues when delta-probe fails (best-effort watermark)\n- 401 → refresh + retry once, with the rotated refresh_token visible\n  in the `onTokenRefreshed` callback\n- 410 → `gone`\n- Tenant id propagates into the refresh URL\n- Unsupported method rejection for the 4 remaining stubs\n\nNext commit: M1f-step — Graph syncIncremental (`/me/messages/delta`),\nsend (`/me/sendMail` with Microsoft's message envelope shape),\nfetchAttachment (`/me/messages/{id}/attachments/{att}/$value`),\nrenewWatch (`/subscriptions` with TTL ≤ ~7 days, lifecycle webhook).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:15.528Z","updatedAt":"2026-06-15T17:00:15.528Z"},{"id":"d00b816d-0c31-433a-b71f-8e33a8d9cf77","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-settings-ui","type":"added","scope":"mailbox","summary":"/settings/mailbox UI — list connected accounts, connect Gmail/Outlook, disconnect with confirm.","body":"M2-e from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Lands the first\n**user-visible** mailbox UI. After this commit, the connect flow is\nfully dogfoodable end-to-end — sign-in to Helios, click Settings →\nMailbox → Connect Gmail, sign in with Google, redirect back, see the\naccount in the list.\n\n**`apps/web/src/routes/settings/mailbox.tsx`**:\n\n- TanStack Router file route at `/settings/mailbox`.\n- **Account list** — `mailbox.account.list_own` via `callAction` +\n  TanStack Query. Renders each connected account with:\n  - Provider glyph (G / M / J / I)\n  - Email address + `default` badge when applicable\n  - Scope badge (personal / business)\n  - Status badge (active / paused / failing / auth_expired)\n  - Last-synced timestamp + last-error message\n  - Disconnect button with inline confirm\n- **Connect buttons** — `mailbox.account.connect` mutation. On success\n  the browser is redirected to the returned `authUrl`. The callback\n  at `/api/mailbox/oauth-callback/<provider>` (landed in M2-c)\n  closes the loop and brings the user back to `/settings/mailbox`\n  via the action's `returnTo` parameter.\n- **Disconnect with confirm** — two-click destructive action.\n  Surfaces server-side errors via toast (`policy_denied` for\n  scope-locked business mailboxes shows \"Locked by admin\" inline\n  instead of a confirm button — matches the `Q14` lock UX).\n- **Loading skeletons + empty state** — uses the existing `Skeleton`\n  + `EmptyState` primitives from `@helios/ui`.\n\n**`apps/web/src/components/modules.tsx`** — adds the **Mailbox**\nentry to the Communication group of the Settings nav, between Email\nand Notices.\n\n**Nav placement decision (per\n`.claude/rules/saas-platform-boundary.md`):** Mailbox lives under\n`/settings/*` (tenant tier) — it's per-user content, not\nplatform-tier admin. The matching admin pages for shared inboxes\n(`/settings/shared-inboxes`) and business mailbox provisioning\n(`/settings/business-mailboxes`) land in M2-f.\n\nTypecheck across `apps/web` is clean.\n\nThis commit closes M2 for the personal-self-connect flow. The user\ncan now:\n1. Click Settings → Mailbox\n2. Click Connect Gmail (or Microsoft 365)\n3. Consent at the provider\n4. Land back on the settings page with the new account in the list\n5. Disconnect later if needed\n\nNext phases (M2-f Pollination): shared-inbox creation + business\nmailbox provisioning (admin-only paths).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.191Z","updatedAt":"2026-06-15T17:00:16.191Z"},{"id":"ad24a7bf-8aa1-4819-8888-b0b619a18905","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-linear-webhook-receiver","type":"added","scope":"roadmap","summary":"Linear webhook receiver auto-syncs issue state changes to roadmap status.","body":"Once an admin attaches a Linear issue URL to a roadmap feature, status changes in Linear now flow back automatically. The new `POST /api/roadmap/webhooks/linear` endpoint verifies each payload with HMAC-SHA256 against a signing secret pasted into Settings → Roadmap, then maps the Linear workflow state to the matching Helios status:\n\n- `triage` / `backlog` → Under review\n- `unstarted` → Planned\n- `started` → In progress\n- `completed` → Shipped\n- `canceled` → Declined (with an auto-filled \"Closed in Linear\" resolution note)\n\nThe flip goes through the existing `feature.set_status` action so the audit log, status-change auto-comment, and email subscriber all fire as if a root operator clicked the button. Replays are idempotent — same-state webhooks land on no-ops and just bump the \"last synced\" timestamp on the admin sheet.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.807Z","updatedAt":"2026-06-15T17:00:16.807Z"},{"id":"876a7b5e-908a-4bd7-bbf6-242e452531a3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-strictness-phases-d-e","type":"added","scope":"web","summary":"Onboarding strictness Phases D + E — admin UI on /saas/onboarding + per-step card on Settings → Organization.","body":"Phases D + E ship the operator-facing dropdowns wired to the\nPhase C actions.\n\n**Phase D — /saas/onboarding** (root only). The existing four\nboolean gates stay; a new \"Per-step strictness\" card lands below\nthem. Each of the 10 catalog steps (5 user, 5 org) shows a\ndropdown with `Strict / Relaxed / Warning / Off`. Picking the\ncatalog default clears the override (`{ stepId: null }`); any\nother value writes a per-step override. The caption next to each\nrow shows `Overridden` when the platform has an explicit override\nset or `Catalog: <level>` when the row is using the catalog\nfallback.\n\n**Phase E — Settings → Organization**, new `OnboardingStrictness\nCard` rendered between TwoFactorRequirementCard and the members\ntable. Same dropdown UI; the caption surfaces the three-level\nprecedence — `Org override` / `Platform: <level>` / `Catalog:\n<level>` — so the owner sees exactly where each effective value\ncomes from. Owners + admins can change; everyone else gets a\nread-only view (matches the existing 2FA card pattern). A \"Reset\nall to platform defaults\" link appears when any org overrides\nare set.\n\nBoth UIs invalidate the relevant query keys on save:\n- D: `platform.onboarding.read`\n- E: `iam.organization.onboarding_strictness.get` +\n  `platform.onboarding.feed.list` (so the dashboard banner reacts\n  immediately to the new level).\n\nThe step catalog is currently mirrored inline in each surface\n(`STEP_CATALOG` in `onboarding.tsx`, `ORG_STRICTNESS_STEPS` in\n`organization.index.tsx`) because the action `read` doesn't yet\nreturn catalog defaults — that's a Phase F polish item. Today\nthe mirror is the contract; changing the underlying catalog in\nmodules/iam or modules/saas requires updating both UI mirrors\ntoo. The duplication is intentional Phase D/E scope; it gets\ncollapsed in Phase F.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.222Z","updatedAt":"2026-06-16T04:14:07.222Z"},{"id":"0fb7e6a9-c78b-4f0b-b0a6-82a0914cc3bc","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-merge-tx","type":"fixed","scope":"roadmap","summary":"feature.merge wraps every write in one transaction so concurrent votes can't desync the target counters.","body":"Merging two roadmap features used to fire ~10 separate statements: move votes, move followers, move comments, soft-delete the source, `COUNT(*)` the target's totals, write the recomputed denorms, insert the merge audit. A concurrent vote landing between the `COUNT` and the denorm write produced a stale `vote_count` that drifted from the actual row count. Every write is now wrapped in `db.transaction(async (tx) => { … })` so the recompute observes an atomic snapshot.\n\n`@helios/testing` gained a pass-through `transaction` shim on `fakeDb` so module unit tests keep running against the same fixture plans. Audit finding **A11** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.331Z","updatedAt":"2026-06-15T19:59:57.331Z"},{"id":"97713351-b1b2-4cce-b7f9-ae473a0fe7b8","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-merge-and-comment-emails","type":"added","scope":"roadmap","summary":"Roadmap merges and comments now send email notifications to immediate-cadence followers.","body":"Two long-deferred event subscribers ship today:\n\n- **Merge notifications.** When an admin merges feature A into feature B, every follower of B (native or migrated from A) on the `immediate` cadence gets an email — native followers learn their request absorbed extra demand; migrated followers learn where the discussion now lives. Subject reads `\"<source> → merged into <target>\"`.\n- **Comment notifications.** Each user-authored comment fans out to every follower except the author themselves whose cadence is `immediate`. Subject reads `\"New comment on <feature title>\"` with a 280-char preview and a deep link. Status-change auto-comments don't trigger this — they keep the existing status-change email path.\n\nBoth flows respect the cadence preferences page; only `immediate` triggers a send today. `shipped_only` and `digest_weekly` stay quiet (the weekly digest cron will pick comment + merge entries up when Phase 3c ships).\n\nAudit findings **B1.b** and **B1.c** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T21:01:17.677Z","updatedAt":"2026-06-15T21:01:17.677Z"},{"id":"55fd5ff4-7ad8-4d9b-b90a-5cef4269e238","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-connect-action","type":"added","scope":"mailbox","summary":"mailbox.account.connect action + HMAC-signed OAuth state-token helper. Starts the connect dance for Gmail and Microsoft 365.","body":"M2 first commit from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Lands the\n**first half** of the account-connect flow — the side-effect-free\naction that issues the OAuth URL with a signed state token. The OAuth\ncallback handler (`mailbox.account.connect.complete`) that exchanges\nthe code, runs the §3.5.1.1 scope resolver server-side, and\nmaterializes the `mailbox_accounts` row lands in the next commit.\n\n**`mailbox.account.connect`** — issues `{authUrl, state}`:\n\n- Side-effect-free: no DB writes, no provider-side calls. A stale or\n  abandoned state token simply times out after 10 minutes.\n- Gated by `mailbox:account:connect:own` (already in the catalogue\n  from M0 commit #2).\n- Inputs: `provider` (`gmail` | `outlook_oauth`), optional `returnTo`\n  (same-origin path the callback should redirect to after success),\n  optional `loginHint` (pre-fills the consent screen for\n  promote-this-account / reconnect flows).\n- Returns the authorization URL the UI redirects the browser to, plus\n  the raw state token (exposed for client-side CSRF cross-check; the\n  primary security is the HMAC signature).\n- Operator misconfig (missing `HELIOS_APP_URL` / `BETTER_AUTH_URL`)\n  returns `dependency_failed` with a clear message rather than\n  surfacing as a generic 500.\n\n**`modules/mailbox/src/lib/state-token.ts`** — HMAC-SHA256-signed\nstate tokens:\n\n- Payload: `{provider, userId, orgId, scopeCandidate, returnTo,\n  nonce, iat, exp}` — every field required.\n- `signStateToken()` produces a 10-minute-TTL token; signed over\n  the JSON-serialised payload with `MAILBOX_OAUTH_STATE_SECRET`.\n- `verifyStateToken()` constant-time signature comparison via\n  `timingSafeEqual`, expiry check, payload-shape validation. Returns\n  `{ok:true, payload}` or `{ok:false, reason: 'malformed' |\n  'bad_signature' | 'expired'}`.\n- 16-byte hex nonce for single-use audit; the callback handler will\n  record consumed nonces in M2-next.\n- Secret-source guards: throws on missing-env or short (<16 chars)\n  secrets so a misconfig fails loudly at sign-time instead of\n  silently weakening the security.\n\n**`modules/mailbox/src/lib/oauth-url.ts`** — pure authorization-URL\nbuilders for both providers:\n\n- Gmail: scope set matches `GmailProvider`'s requirements\n  (gmail.readonly + gmail.modify + gmail.send + openid/email/profile).\n  Includes `access_type=offline` + `prompt=consent` to guarantee\n  refresh-token issuance.\n- Graph: scope set matches `GraphProvider`'s requirements\n  (Mail.Read + Mail.ReadWrite + Mail.Send + MailboxSettings.Read +\n  offline_access + openid/email/profile). Configurable tenant id\n  (default `'common'` for multi-tenant apps).\n- Redirect URI: `{APP_URL}/api/mailbox/oauth-callback/{provider}` —\n  per-provider path so the callback handler routes to the matching\n  provider impl without parsing the state token first.\n- Throws on missing `GOOGLE_OAUTH_CLIENT_ID` / `MICROSOFT_OAUTH_CLIENT_ID`\n  env vars (operator registers the OAuth apps + pastes the IDs).\n\n**Test coverage:** 25 new mailbox-module tests (51 total now):\n- 10 state-token (round-trip, tampered payload, secret mismatch,\n  expiry, missing-field rejection, env-secret guards)\n- 8 OAuth URL builder (Gmail + Graph scopes / redirect / login_hint /\n  tenant id / trailing-slash handling / missing-env guards)\n- 7 action (happy path with state verification, policy denial,\n  fallback to BETTER_AUTH_URL, dependency_failed on misconfig,\n  returnTo round-trip, loginHint propagation, Graph URL shape)\n\nNext: `mailbox.account.connect.complete` — OAuth callback handler\nthat exchanges the code, runs `classifyConnectScope` authoritatively,\nre-verifies the state token, encrypts the OAuth tokens via\n`@helios/email`'s AES-256-GCM envelope, and writes the\n`mailbox_accounts` row.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:15.538Z","updatedAt":"2026-06-15T17:00:15.538Z"},{"id":"c9980cb2-44a4-4fea-8d88-b819624b8922","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-category-dedup-overrides","type":"added","scope":"roadmap","summary":"Per-category dedup similarity threshold overrides on the roadmap settings page.","body":"The roadmap settings page now lets operators override the dedup similarity threshold per category. Categories that submit similar-shaped requests (design, projects) can be tightened to 0.90+ for fewer false positives; sprawling categories (platform, other) can be loosened to 0.78–0.82 to catch more duplicates. Missing values fall through to the global default. Both the live \"Find similar\" probe and the submit safety-net honor the override based on the category the requester selected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.193Z","updatedAt":"2026-06-15T17:00:16.193Z"},{"id":"9fcb150b-cb1a-4f3f-9487-fccab80b8563","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-graph-complete","type":"added","scope":"mailbox","summary":"Graph syncIncremental + send + fetchAttachment + renewWatch + stopWatch — GraphProvider complete.","body":"Closes out M1f. After this commit `GraphProvider` implements every\n`MailboxProvider` method, mirroring `GmailProvider`'s completeness.\n\n**`syncIncremental(account, watermark)`** — Graph delta query:\n- GET the `@odata.deltaLink` URL from the watermark (paginated via\n  `@odata.nextLink`). The response's `value` array carries new + changed\n  messages PLUS removed entries marked `{id, '@removed': {reason}}` —\n  separator + translator + accumulator follow the same shape as\n  Gmail's history.list aggregation.\n- 410 → `gone` → `hint: 'rebootstrap'` (caller switches to\n  bootstrap-over-30-days, same as Gmail's stale-historyId path).\n- Empty watermark → `hint: 'rebootstrap'` immediately.\n- Keeps the prior `deltaLink` in `newWatermark` when the response\n  doesn't carry a new one (in-progress sync; pagination not exhausted).\n- Graph delta doesn't emit per-message label deltas — `labelChanges`\n  is always `[]`. Category changes flow through as full-record updates.\n\n**`send(account, request)`** — Graph `/me/sendMail`:\n- DOES NOT reuse the Gmail MIME builder. Graph wants a structured\n  `Message` object and composes RFC 5322 server-side. This commit adds\n  a tiny `buildGraphMessage()` helper that adapts our\n  provider-neutral `SendRequest` → Graph's `Message` JSON shape:\n  - `body: {contentType:'html'|'text', content}`\n  - `toRecipients` / `ccRecipients` / `bccRecipients` /\n    `replyTo` / `from` in Graph's\n    `{emailAddress:{address,name}}` envelope shape\n  - Threading via `internetMessageHeaders` array — In-Reply-To +\n    References as RFC 5322 headers\n  - Inline + storage-keyed attachments → `#microsoft.graph.fileAttachment`\n    nodes with base64 `contentBytes`. Storage attachments resolved via\n    `resolveStorageAttachment` callback (same shape as Gmail's MIME\n    builder).\n- POSTs `/me/sendMail` with `{message, saveToSentItems: true}`.\n- Graph returns 202 with no body. We synthesize a placeholder\n  `messageIdHeader` for the local draft row; the next sync reconciles\n  the local row to the Graph-issued id when the sent message arrives\n  in the Sent Items folder.\n\n**`fetchAttachment(account, ref)`** — JSON `FileAttachment` endpoint\nthat returns `contentBytes` (base64). Decode → Buffer. Uses the\nclient's normal `request()` pipeline so retries / refreshes apply.\n(Switching to `$value` + streaming is a M5+ optimization for >50 MB\nattachments.)\n\n**`renewWatch(account)`** — Graph `/subscriptions`:\n- Reads `MAILBOX_GRAPH_WEBHOOK_URL` (required) +\n  `MAILBOX_GRAPH_LIFECYCLE_URL` (optional). Returns `null` when the\n  webhook env is unset → operator opted out of push.\n- Posts `{changeType:'created,updated,deleted', notificationUrl,\n  resource:'/me/messages', expirationDateTime:<+7d>, clientState:'mb:<accountId>'}`.\n- Subscription TTL: 7 days (Graph's max for `/me/messages` without\n  `includeResourceData`).\n- `clientState` is `mb:<accountId>` — the M4 webhook handler verifies\n  this to correlate incoming Pub/Sub-style notifications back to the\n  account row.\n\n**`stopWatch(account, subscriptionId)`** — DELETE\n`/subscriptions/{id}`. Treats 404 (already gone) and 4xx as silent\nsuccess; re-throws auth_expired etc.\n\n**Test coverage:** 14 new Graph provider tests (156 total in\nmailbox-sync now): syncIncremental with added/removed/pagination/410,\nprior-deltaLink preservation, send envelope shape + threading + inline\n+ storage attachments + storage-without-resolver, fetchAttachment\ndecode + missing-contentBytes, renewWatch with + without lifecycle\nURL + null when webhook env unset, stopWatch 404 silent + auth_expired\nre-throw.\n\nAfter this commit, the active provider matrix is:\n\n| Provider | testConnection | bootstrap | syncIncremental | send | fetchAttachment | renewWatch |\n|---|---|---|---|---|---|---|\n| Gmail  | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| Graph  | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |\n| IMAP   | ⏸ M1g | ⏸ | ⏸ | ⏸ | ⏸ | n/a |\n| JMAP   | ⏸ M1h | ⏸ | ⏸ | ⏸ | ⏸ | ⏸ |\n\nNext: M1g — IMAP provider. Substantially different shape (raw\n`net`/`tls` sockets, CONDSTORE + QRESYNC + IDLE for sync, RFC 5322\nMIME parsing for messages, SMTP submission for send). The\nprovider-neutral RawMessage type is what makes it slot in cleanly.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.483Z","updatedAt":"2026-06-15T17:00:16.483Z"},{"id":"4405c8b8-6412-49e5-808c-aaecdbbb3534","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-linear-webhook-admin-ui","type":"added","scope":"roadmap","summary":"Admin settings: Linear webhook secret card with rotate, disconnect, and copy webhook URL.","body":"The /saas/roadmap settings page now has a Linear webhook card. Operators see a \"Configured\" / \"Not configured\" pill, can copy the receiver URL to paste into Linear's webhook config, paste a fresh signing secret to set or rotate, and disconnect with one click. The secret itself is never echoed back — the page only shows whether one is set.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.645Z","updatedAt":"2026-06-15T17:00:16.645Z"},{"id":"e6f6bb2e-489a-4170-b6a9-1ffb3330af20","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-reembed-on-create-and-update","type":"fixed","scope":"roadmap","summary":"Roadmap features now re-embed on admin create + every text edit, keeping similarity search accurate.","body":"The pgvector dedup pipeline used to run only at customer submission. Admin-authored features (`platform.roadmap.feature.create`) shipped with `embedding = NULL` and were invisible to all future similarity searches; edits via `feature.update` left a stale vector pointing at the pre-edit title and summary. Both paths now share the same `buildAndEmbed` helper from `modules/roadmap/src/lib/embedding.ts` and refresh the vector whenever the dedup text fields (title / summary / use case / body) change.\n\nAudit findings **A1** and **A2** from `docs/plans/PLATFORM_ROADMAP_MODULE_AUDIT_AND_IMPROVEMENT_PLAN.md` — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.340Z","updatedAt":"2026-06-15T19:59:57.340Z"},{"id":"61c7c104-156a-49f7-8e30-7cbf91f79cbd","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-ai-agent-form","type":"changed","scope":"support","summary":"AI agent editor uses the standard form stack with inline validation.","body":"The Settings → Support → AI agents create/edit dialog — the last and\nlargest hand-rolled form in the support admin — moved to the shared\n`useAppForm` + `<Form>` Zod stack, with inline validation on the name\nfield. Model via FormSelect; system prompt, tool allow-list, and handoff\nkeywords via form-bound textareas; the confidence threshold via a numeric\nfield. The tool-allow-list / handoff-keyword / language parsing and the\nfull agent payload are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.383Z","updatedAt":"2026-06-15T19:59:57.383Z"},{"id":"ab29cfcc-27e2-4b03-9cf6-52d7db168a60","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-r5-perf-and-observability","type":"performance","scope":"roadmap","summary":"Roadmap feeds get ETags, key tables get indexes, crons emit heartbeats, widget bails after 5 s.","body":"A bundle of small operational fixes from Phase R-5 of the roadmap audit:\n\n- **ETags on the public feeds** (`/api/roadmap.json` + `.rss`). Clients that send `If-None-Match` now get a 304 with no body when nothing has changed since their last fetch. RSS aggregators polling at the default 60 s cache window save the whole payload on every empty tick.\n- **Three new indexes**: `platform_roadmap_features.created_by` (partial on alive rows) backs the new admin Filed-by filter and the per-user recent-submissions list; `platform_roadmap_votes.created_at` backs the exponential-decay trending math; `platform_roadmap_votes.cast_on_behalf_by` (partial on non-null) backs the captured-votes admin listing.\n- **Linear webhook content-length short-circuit** — declared body sizes over 256 KB get a 413 before any bytes are read.\n- **Cron heartbeats** — the trending recompute and dedup sweep crons now emit a single \"heartbeat (no churn)\" line periodically even when nothing has changed, so ops can distinguish a healthy idle cron from a stuck one.\n- **Roadmap pill widget** bails after a 5 s `AbortSignal.timeout` so a slow API can't leave the embed hanging on a partner's page.\n\nAudit findings **G1, C2, C3, C4, A4, F1, F2, G4** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:58.118Z","updatedAt":"2026-06-15T19:59:58.118Z"},{"id":"24342e1d-2845-482f-b7d8-13a0e3101817","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-connect-complete","type":"added","scope":"mailbox","summary":"mailbox.account.connect.complete — OAuth callback handler that materialises the mailbox_accounts row.","body":"M2 second commit from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Lands\nthe OAuth callback handler that closes the connect loop opened by\nthe prior `mailbox.account.connect` commit. Together, these two\nactions are the first end-to-end **user-visible** mailbox surface —\nonce UI lands (next commit), a user can click \"Connect Gmail\" and\nhave a real `mailbox_accounts` row at the end.\n\n**`mailbox.account.connect.complete`** pipeline:\n\n1. **Verify state token** — HMAC + expiry + payload-shape checks.\n   Malformed / expired / bad-signature → `validation_failed`.\n2. **Cross-check actor identity** — `payload.userId === ctx.actor.id`.\n   Guards against a logged-in user landing on someone else's callback\n   URL. Mismatch → `policy_denied`.\n3. **Exchange OAuth code** for tokens at the provider's token\n   endpoint. Maps provider errors onto action-result codes:\n   - `invalid_grant` (code already used / expired) → `validation_failed`\n   - 429 → `rate_limited`\n   - 500 / network → `service_unavailable`\n   - Missing client secret env → `dependency_failed`\n4. **Fetch provider profile** to get the authoritative email address.\n   Gmail: `users.me.profile`. Graph: `/me` (mail → fall back to\n   userPrincipalName).\n5. **Run `classifyConnectScope`** authoritatively. The state's\n   `scopeCandidate` is a hint; the resolver re-runs server-side\n   against the provider-confirmed email so a tampered URL cannot\n   escalate scope.\n6. **Encrypt OAuth tokens** via `@helios/email`'s AES-256-GCM\n   envelope (`encryptValue`). The tokens never sit in `mailbox_accounts`\n   as plaintext; CHECK constraint #2 (org_id null⇔personal,\n   non-null⇔business/shared) enforced by the schema.\n7. **Idempotency check** — re-running the same OAuth flow for the\n   same `(provider, user_id, email_address)` UPDATEs the existing row\n   instead of creating a duplicate. Useful for reconnect-after-revoke\n   flows where the user re-grants access.\n8. **INSERT** the new row when not idempotent — with the right\n   `scope`, `org_id`, `domainAttestedAt` (set for verified-business\n   domains), encrypted tokens, scopes array, status='active'.\n\n**New helper `modules/mailbox/src/lib/oauth-code-exchange.ts`:**\n\n- `exchangeOAuthCode({provider, code, redirectUri, fetchImpl?, tenant?})`\n  — swaps the `authorization_code` for tokens. Reuses the provider\n  auth-helper error-mapping conventions (auth_expired / quota_exceeded\n  / provider_failed / network / unsupported).\n- `fetchProviderProfile({provider, accessToken, fetchImpl?})` — GETs\n  the profile endpoint with the fresh access token. Gmail returns\n  `{emailAddress}`; Graph returns `{mail, userPrincipalName,\n  displayName}` (mail wins; UPN fallback for tenants where mail is\n  unset — common on `*.onmicrosoft.com` accounts).\n\n**Test coverage:** 10 new PGlite-backed tests (61 total in\nmailbox-module):\n- Happy path personal (gmail.com → scope=personal, org_id NULL,\n  tokens are AES-GCM ciphertext not plaintext)\n- Happy path business (verified business-mail domain → scope=business,\n  org_id set, domainAttestedAt set)\n- Idempotent reconnect (re-running with same email UPDATEs the\n  existing row; row count stays 1)\n- returnTo round-trips through the state token into the result\n- State token malformed → validation_failed\n- State token expired → validation_failed\n- State user mismatch with session actor → policy_denied\n- OAuth invalid_grant → validation_failed\n- OAuth 500 → service_unavailable\n- Policy denial when actor lacks `mailbox:account:connect:own`\n\nAfter this commit, the connect flow is end-to-end functional. Next\ncommit: the `/api/mailbox/oauth-callback/{provider}` route that\nreceives the OAuth redirect and invokes this action, plus the\n`/settings/mailbox` UI that opens the connect flow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:15.542Z","updatedAt":"2026-06-15T17:00:15.542Z"},{"id":"07523391-9efe-4cbb-b161-eb0efc3ffc54","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-list-disconnect-own","type":"added","scope":"mailbox","summary":"mailbox.account.list_own + mailbox.account.disconnect_own — the read + soft-delete halves of the per-user mailbox surface.","body":"M2-d from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Lands the two\nactions the upcoming `/settings/mailbox` UI needs to render the\naccount list + offer a disconnect button.\n\n**`mailbox.account.list_own`** — read-only:\n\n- Returns the actor user_id's own personal + business mailbox\n  accounts. Shared inboxes are excluded (separate\n  `mailbox.shared.list` action lands with the shared-inbox flow).\n- Excludes soft-deleted rows (`deleted_at IS NOT NULL`).\n- Order: `is_default DESC, created_at DESC` so the default account\n  surfaces first.\n- Output is metadata only: `{id, scope, provider, emailAddress,\n  displayName, status, isDefault, scopeLockedByAdmin, lastSyncedAt,\n  lastError, createdAt}`. No OAuth tokens, no body content.\n- Policy: `mailbox:account:read:own`.\n\n**`mailbox.account.disconnect_own`** — soft-delete:\n\n- Marks the row `deleted_at = now()`, sets `status='disabled'`,\n  clears `oauth_tokens_encrypted` (defense-in-depth — the row is\n  gone but anyone with backup-recovery access shouldn't see token\n  blobs), drops `is_default`.\n- **Blocked on `scope_locked_by_admin=true` business accounts** —\n  the Q14 finance/healthcare lock that prevents user-side\n  disconnect. Surfaces as `policy_denied` with a clear message\n  directing the user to their admin.\n- Rejects shared inboxes — routes the caller to\n  `mailbox.shared.disconnect` instead (different access rule).\n- Rejects accounts not owned by the actor.\n- Does NOT call `provider.stopWatch` — that requires a live access\n  token + the runtime provider registry. The watch-renewal cron in\n  M4 notices `deleted_at IS NOT NULL` rows and fires stopWatch with\n  the last-known token. Decoupling keeps the disconnect action fast\n  + offline-safe.\n- Re-connect with the same `(provider, email_address, user_id)`\n  works after disconnect because the unique indexes are\n  partial-on-(`deleted_at IS NULL`). Verified by test.\n- Policy: `mailbox:account:disconnect:own`. Tagged `dangerous` so\n  the AI runtime renders a confirmation card before invoking.\n\n**Test coverage:** 10 new PGlite-backed tests (71 total in\nmailbox-module):\n- list_own excludes shared / soft-deleted / other users' rows;\n  orders default-first\n- list_own empty array for users with no accounts\n- list_own policy denial\n- disconnect_own soft-deletes + clears tokens + sets status\n- disconnect_own blocks scope_locked_by_admin business accounts\n- disconnect_own rejects accounts not owned by actor\n- disconnect_own rejects shared inboxes\n- disconnect_own returns `not_found` for missing accounts\n- disconnect_own + reconnect cycle works (partial unique index)\n- disconnect_own policy denial\n\nAfter this commit, the action layer for personal + business\nconnect/list/disconnect is complete. Next: the `/settings/mailbox`\nTanStack route + UI that consumes these three actions\n(connect / list_own / disconnect_own).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.574Z","updatedAt":"2026-06-15T17:00:16.574Z"},{"id":"c9723b82-cf81-45f2-8dfb-1eefdba135a0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-admin-a11y-polish","type":"changed","scope":"support","summary":"Accessibility + polish pass across the org-level support admin pages.","body":"Polished the organization-level support settings pages: icon-only edit /\ndelete / remove buttons across Widgets, Email templates, Menus, Docs, AI\nagents, and Status now have `aria-label`s; the Reports saved-view chip no\nlonger nests a delete control inside another button (invalid HTML) and the\ndelete is a properly-labelled sibling; the AI message accent border in AI\nTest and the live console uses the `--accent-ai-fg` design token instead\nof a hard-coded `purple-500`; KB article + analytics helpful/unhelpful\ncounts use Phosphor thumb icons with labels instead of emoji; and the\nTriggers, Automations, Macros, and Canned replies lists show loading\nskeletons instead of a blank card.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.362Z","updatedAt":"2026-06-15T19:59:57.362Z"},{"id":"b0a74e6a-26ba-484e-97cb-2ae2bfa50f7a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-canned-reply-usage-count","type":"fixed","scope":"support","summary":"Canned replies now track how many times they've been used.","body":"Rendering a canned reply into a ticket now increments its usage counter, so\nthe canned-replies management list's \"Used N times\" reflects real usage\ninstead of always showing 0. The increment is atomic, so concurrent renders\ncan't lose a count.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.636Z","updatedAt":"2026-06-15T22:33:59.636Z"},{"id":"a5d04fe4-90b3-4630-824a-5615d020675a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-sync-unused-param","type":"fixed","scope":"mailbox","summary":"Underscore-prefix the unused `url` param in a Graph provider test so noUnusedParameters typechecks pass.","body":"`graph/provider.test.ts` had a `fetchImpl` mock whose `url`\npositional parameter was destructured for the signature but\nnever read in the body (the test asserts on the request body\nonly). Renamed to `_url` to satisfy `noUnusedParameters`. No\nbehaviour change.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.133Z","updatedAt":"2026-06-15T17:00:16.133Z"},{"id":"d5e74f61-4907-4425-bfaf-d8a7c362a648","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-graph-translate-auth","type":"added","scope":"mailbox","summary":"Microsoft Graph translator (Graph Message → RawMessage) + OAuth helpers — pure-logic foundations for the Graph provider.","body":"M1f (first commit) from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M1.\nShips the Graph provider's pure-logic foundations — translator + auth\n— mirroring the M1b shape that landed Gmail's. The HTTP client +\n6-method provider lands in M1f-next (the same shape as Gmail's\nM1c-e).\n\n**`packages/mailbox-sync/src/providers/graph/translate.ts`** — pure\ntransformation of Microsoft Graph's `Message` JSON into our\nprovider-neutral `RawMessage`. Differences from Gmail handled\nsilently:\n\n- Graph's body is a single `{contentType:'html'|'text', content}` not\n  a multipart MIME tree — translator picks ONE representation\n  (whichever Graph returns) and emits as `bodyHtml` OR `bodyText`.\n- Threading via `conversationId` (Graph's thread identifier) instead\n  of Gmail's `threadId`. Also extracts `internetMessageId`,\n  `internetMessageHeaders`, and `inReplyToId` for cross-provider\n  correlation.\n- Attachments live in a separate `/attachments` collection — the\n  translator emits attachment metadata when the caller `$expanded`\n  them via `?$expand=attachments(...)`; empty array otherwise.\n- Folders + categories surface as `providerLabels` (prefixed\n  `folder:` and `category:` so the mailbox ingest path can keep them\n  apart).\n- Read/flagged state comes from top-level `isRead` + `flag.flagStatus`.\n- SPF/DKIM/DMARC parsed from `Authentication-Results` header when\n  available in `internetMessageHeaders`.\n\nPermissive address parsing: Graph's `emailAddress.name` is often\nidentical to `address` — the translator drops the redundant name in\nthat case so the consumer doesn't see `{ name:'alice@x.com',\nemail:'alice@x.com'}`.\n\n**`packages/mailbox-sync/src/providers/graph/auth.ts`** — Graph\nOAuth helpers:\n\n- `refreshGraphAccessToken({refreshToken, tenant?, fetchImpl?})` —\n  POSTs `login.microsoftonline.com/{tenant}/oauth2/v2.0/token`.\n  Multi-tenant default: `tenant='common'`. Maps Microsoft errors onto\n  `ProviderError`: `invalid_grant` + `invalid_request` →\n  `auth_expired`, 429 → `quota_exceeded` with `retryAfterMs`, 5xx →\n  `provider_failed`, 4xx → `bad_request`, fetch reject → `network`.\n- **Returns the rotated `refresh_token`** when Microsoft issues a new\n  one. Microsoft rotates refresh tokens unpredictably; failing to\n  persist the new one causes the next refresh to fail with\n  `invalid_grant`. The caller MUST persist the rotated token when\n  `result.refreshToken != null` (Gmail doesn't rotate, so this field\n  doesn't exist on the Gmail helper).\n- `checkGraphScopes(scopes)` — validates `Mail.Read` +\n  `Mail.ReadWrite` + `Mail.Send` + `MailboxSettings.Read` +\n  `offline_access`. Accepts BOTH full-URL form (`https://graph.microsoft.com/Mail.Read`)\n  AND short form (`Mail.Read`) — Microsoft tokens may use either.\n- `parseRetryAfter` + `isAccessTokenFresh` — same shape as the Gmail\n  helpers (duplicated rather than cross-imported so providers stay\n  independent).\n\n**Test coverage:** 35 new tests (130 total in mailbox-sync now):\n- 17 Graph translate (envelope, body extraction, attachment expansion,\n  read/flagged state, address parsing quirks)\n- 18 Graph auth (success + refresh-token rotation + every\n  ProviderError branch + tenant id parameterisation + short-form scope\n  acceptance)\n\nNext: M1f-next — Graph HTTP client + provider implementing\ntestConnection / bootstrap / syncIncremental (delta query) / send\n($batch / sendMail) / fetchAttachment / renewWatch (subscriptions\nwith TTL ≤ ~7 days).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.511Z","updatedAt":"2026-06-15T17:00:16.511Z"},{"id":"0fb95dd7-f988-4640-ba4c-a893ba87854a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-duplicates-queue","type":"added","scope":"roadmap","summary":"Duplicate-candidates queue groundwork — schema + list/dismiss actions ready for the sweep job.","body":"A new admin-facing duplicates queue is wired into the roadmap module. The `platform_roadmap_duplicate_candidates` table stores ordered (low, high) feature pairs flagged as similar; `platform.roadmap.duplicates.list_admin` returns the active queue with both sides joined for side-by-side preview; `platform.roadmap.duplicates.dismiss` lets root operators mark a pair as \"not a duplicate\" so the future background sweep skips it. The background scan that populates the queue and the admin UI route arrive in follow-up commits.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.658Z","updatedAt":"2026-06-15T17:00:16.658Z"},{"id":"c1263618-4334-46cc-a5eb-0fb719f0e746","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-my-company-scope","type":"security","scope":"support","summary":"Agent inbox now honours the my-company read scope and closes an over-broad read.","body":"The agent inbox (`support.ticket.list`) scope branches were mutually\nexclusive, so an actor holding `support:ticket:read:own` **and**\n`:read:my_company` matched no branch and saw **every** ticket in the org.\nThe scope filters are now OR-combined: an actor sees the union of the\ntickets they're assigned to / requested and — when they hold\n`:my_company` — tickets whose requester company is one of their CRM\ncompanies (resolved with the same helper the customer portal uses). This\nboth finishes the previously-stubbed `:my_company` scope and removes the\nover-broad read.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.447Z","updatedAt":"2026-06-15T19:59:57.447Z"},{"id":"08ce995b-8fa5-4306-9f8e-a2d38fb8070b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-inbound-test-action","type":"added","scope":"forms","summary":"New \"Test mapping\" tool dry-runs an inbound payload to preview field mapping and validation without creating a lead.","body":"Added `forms.inbound.test` — a read-only action (gated by\n`forms:definition:update`) that takes a raw JSON or form-encoded body, maps it\nonto a form's fields exactly as the live inbound endpoint does, and re-runs\nserver-side validation, all without writing a submission or dispatching a lead.\nThe form editor's \"Website integration\" panel surfaces it as a \"Test mapping\"\nbox: paste a sample payload, see which keys map to which fields, what stays in\nthe raw bucket, and whether it would validate. Phase C of the website-integration\nplan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.079Z","updatedAt":"2026-06-15T22:14:01.079Z"},{"id":"1f15991f-a7bc-4d0d-8792-2ccbf14fd54c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-admin-tracker-block","type":"added","scope":"roadmap","summary":"Admin feature sheet now shows the external tracker link with \"last synced N ago\".","body":"The roadmap admin sheet gains an \"External tracker\" section. Operators see whether a feature is linked, which provider (Linear, GitHub, Jira, ClickUp), and a relative \"last synced\" timestamp that the Linear webhook receiver bumps on every inbound event — proving the connection is live. Attach, update, or detach is one click; for Linear, attaching it activates the bidirectional status sync described in the webhook receiver release notes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.427Z","updatedAt":"2026-06-15T17:00:16.427Z"},{"id":"e2a7ba4e-778f-415d-b113-2f90d838792f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-vote-on-behalf-picker","type":"changed","scope":"roadmap","summary":"Vote-on-behalf admin flow replaces UUID input with cross-tenant typed-ahead picker.","body":"The admin \"Capture customer vote\" block in the roadmap feature sheet now searches users by name or email — no more pasting UUIDs. Each match shows the user's primary org as disambiguating context so operators can confirm they have the right person before logging a vote. Search is debounced and root-only via the new `platform.roadmap.vote.search_users_admin` action.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.828Z","updatedAt":"2026-06-15T17:00:16.828Z"},{"id":"7db31cc2-3b37-495f-ab95-3d612886e84b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-automations-form","type":"changed","scope":"support","summary":"Support automation editor now uses the standard form stack with inline validation.","body":"The Settings → Support → Automations create/edit dialog was a hand-rolled\n`useState`-per-field form; it now uses the shared `useAppForm` + `<Form>`\nZod stack (matching the sibling Triggers editor), giving inline field\nvalidation on name/schedule and a consistent submit state. The condition /\naction JSON editors and the parse-on-submit behaviour are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.448Z","updatedAt":"2026-06-15T19:59:57.448Z"},{"id":"818138f8-acc5-495a-8af5-f08625d0be8c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-watchers-ui","type":"added","scope":"support","summary":"Agents can add and remove ticket watchers from the detail sidebar.","body":"The ticket detail sidebar now has a Watchers block: it lists the ticket's\ncurrent watchers, lets an agent add one via the member picker, and remove\nany with one click. The `support.ticket.watcher.add` / `.remove` actions\nexisted but had no UI; this wires them up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T21:01:17.907Z","updatedAt":"2026-06-15T21:01:17.907Z"},{"id":"4c335eed-1e88-4b7d-91e0-3e2476753db5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"login-history-card-extracted","type":"changed","scope":"web","summary":"LoginHistoryCard extracted from settings/security.tsx (1630 → 1438 lines).","body":"Pure code-organisation. The \"Login history\" card — read-only\ntimeline of recent sign-ins with new-device fingerprint\ndetection — moves to\n`apps/web/src/components/login-history-card.tsx` (208 lines).\n\nSame `iam.user.loginHistory.list` call, same fingerprint\nwalk (newest-first → walks backwards to mark first\noccurrence), same stats strip + show-all toggle.\n\nsecurity.tsx: 1630 → 1438 lines. The now-unused `useMemo`\nimport from `react` is also gone.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.098Z","updatedAt":"2026-06-15T22:14:01.098Z"},{"id":"2e46f0c4-0d56-4da0-b3f4-657cec8f5f41","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-dedup-sweep-cron","type":"added","scope":"roadmap","summary":"Daily background sweep populates the roadmap duplicate-candidates queue.","body":"A daily worker cron now scans recently created or updated roadmap features and queues likely + near-certain similar pairs for admin review at /saas/roadmap/duplicates. The sweep honors the per-category dedup thresholds, dedups by ordered pair so the same duplicate can't queue twice, and skips pairs an admin already dismissed — vetoes stick. Cadence is configurable via `ROADMAP_DEDUP_SWEEP_INTERVAL_MIN` and `ROADMAP_DEDUP_SWEEP_LOOKBACK_DAYS`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.511Z","updatedAt":"2026-06-15T17:00:16.511Z"},{"id":"78394915-dca2-4f8c-8358-a9deb658aa8c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-duplicates-admin-tab","type":"added","scope":"roadmap","summary":"New Duplicates tab in /saas/roadmap surfaces the dedup queue with merge + dismiss in two clicks.","body":"The roadmap admin console gains a Duplicates tab listing every pair flagged by the daily dedup sweep. Each card shows both candidates side-by-side with title, summary, status, and vote count; tier and score are surfaced as chips. Operators can merge either direction (votes / followers / comments transfer via the existing `feature.merge`) or dismiss with an optional reason — dismissed pairs never re-queue. Filter by tier (very likely / likely / possibly related) or toggle \"Include dismissed\" to revisit prior vetoes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:16.627Z","updatedAt":"2026-06-15T17:00:16.627Z"},{"id":"00d4a780-10b5-49b8-aa08-29af5610fd29","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-merge-chain-redirect","type":"fixed","scope":"roadmap","summary":"Roadmap detail page now follows multi-hop merge chains (A→B→C) instead of 410-ing at the second hop.","body":"Public detail-page lookups via `platform.roadmap.feature.get_public` used to walk `meta.merged_into` exactly one hop. If a feature was merged twice (A → B and later B → C), visiting A's slug would land on B's still-soft-deleted row and return \"Feature not found\" instead of redirecting to C. The walker now follows up to five hops with cycle detection so a pathological data corruption can't infinite-loop.\n\nAudit finding **A7** from `docs/plans/PLATFORM_ROADMAP_MODULE_AUDIT_AND_IMPROVEMENT_PLAN.md` — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.500Z","updatedAt":"2026-06-15T19:59:57.500Z"},{"id":"1e0e83f0-9702-491d-bd3e-ffe2fbf8657a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-requester-autolink","type":"added","scope":"support","summary":"Tickets filed by email auto-link to the matching CRM contact's customer + company.","body":"When a ticket is created with a requester email but no user/company id\n(public contact form, inbound email, widget offline form, agent-on-behalf),\nthe create action now matches the email against `crm_contacts` in the org\nand backfills the contact's linked user and company. So a guest ticket\nthreads to the right customer: it shows up in that customer's portal, is\ncaught by the `:my_company` read scope, and the agent's requester panel can\ndeep-link to the contact/company. Caller-supplied ids are never overwritten,\nand no match leaves the ticket as a plain guest as before.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.589Z","updatedAt":"2026-06-15T19:59:57.589Z"},{"id":"93a31821-e907-481a-8c15-3343d3249609","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-imap-smtp-connect","type":"added","scope":"mailbox","summary":"Connect Fastmail / iCloud / ProtonMail Bridge / self-hosted mailboxes via IMAP + SMTP from /settings/mailbox.","body":"Users can now connect any IMAP+SMTP mailbox alongside the existing\nGmail / Microsoft 365 OAuth flows. Fastmail, iCloud, ProtonMail Bridge,\nand any self-hosted IMAP server work out of the box.\n\n## How to connect\n\n`/settings/mailbox` → **Connect IMAP / SMTP…** opens a sheet with:\n- Email address + display name\n- IMAP host / port / username / password (TLS on 993 by default;\n  flip to STARTTLS on 143 if the server requires it).\n- Optional SMTP block — host / port / credentials / implicit-TLS\n  toggle. Defaults to the IMAP username + password when blank so the\n  common \"same credentials for inbound + outbound\" case is one\n  click. Leave SMTP off entirely and outbound sending stays disabled\n  until you re-run connect.\n\nBefore saving we open a real IMAP session and `STATUS INBOX` against\nthe supplied credentials. A bad password fails fast with a clear\ntoast — no broken-but-saved account row.\n\n## Platform admin: host allow-list\n\n`/saas/mailbox` → **IMAP host allow-list** lets root operators\nrestrict which IMAP hosts tenants can connect. Empty list = any host\npermitted (the default). Patterns support `*` globs\n(e.g. `*.fastmail.com`). Case-insensitive.\n\n## Security gates\n\n- **Q15 impersonation fence.** Refuses to plant a mailbox while\n  impersonating a user — credentials-form path is now consistent with\n  the OAuth-state-token round-trip.\n- **Encryption-required.** The action refuses to save when\n  `HELIOS_DATA_ENCRYPTION_KEY` is unset. Unlike OAuth tokens (which\n  could fall through to a legacy plaintext path for back-compat), an\n  IMAP password is captured fresh — there's no legacy to preserve and\n  silently storing it in cleartext would be a sharper failure mode.\n- **Scope classifier.** Same authoritative scope decision as the\n  OAuth path — a verified business-mail domain produces a\n  `scope=business` row; everything else stays personal.\n- **Idempotency.** Re-running connect with the same `(provider, user,\n  email)` refreshes the existing row's credentials in place. No\n  duplicate accounts on password rotation.\n\n## Worker\n\n`ImapProvider` is registered in the mailbox-sync runtime for both the\n`imap` and `office365_imap` provider enums, so sync, send, and\ntest-connection paths route correctly. IMAP doesn't have a labels\nmodel, so the outbound `applyLabelChange` push step skips IMAP\naccounts cleanly (instead of double-counting a no-op as \"pushed\").\n\n## Reference\n\n- Action: [modules/mailbox/src/actions/connect-imap.ts](../../modules/mailbox/src/actions/connect-imap.ts)\n- Provider: [packages/mailbox-sync/src/providers/imap/provider.ts](../../packages/mailbox-sync/src/providers/imap/provider.ts)\n- UI: [apps/web/src/routes/settings/mailbox.tsx](../../apps/web/src/routes/settings/mailbox.tsx) (`ImapConnectSheet`)\n- Tests: [modules/mailbox/src/actions/connect-imap.test.ts](../../modules/mailbox/src/actions/connect-imap.test.ts) (15 cases — happy path, allow-list, glob match, business demotion, probe-fail, idempotent re-connect, Q15 fence, encryption-required)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.225Z","updatedAt":"2026-06-16T04:14:07.225Z"},{"id":"7a30423d-16a3-4177-8ca9-4054e20e8fb7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-status-incident-form","type":"changed","scope":"support","summary":"Status incident editor uses the standard form stack with conditional validation.","body":"The Settings → Support → Status incident create / update dialog moved from\na hand-rolled `useState`-per-field form to the shared `useAppForm` +\n`<Form>` Zod stack. Title is validated as required only when creating a new\nincident (editing just posts a status update + body), the affected-component\nchips are an accessible `aria-pressed` multi-select bound to a form field,\nand the update body is required. Both the create and update payloads are\nunchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.628Z","updatedAt":"2026-06-15T19:59:57.628Z"},{"id":"b504aae1-312c-4b67-86b7-0ae0e92ff920","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-customer-only-visibility","type":"added","scope":"roadmap","summary":"Customer-only roadmap items now show to subscribed orgs, not just root operators.","body":"Features set to the `customer_only` visibility tier used to be hidden from every authenticated user except root operators. The plan-membership check that the spec promised was tracked as a Phase 2b TODO that never landed. It now joins `saas_subscriptions` on the actor's org and surfaces the tier whenever the org has an active or trialing subscription. Root operators continue to see every tier including `private`.\n\nAudit finding **A9** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:58.106Z","updatedAt":"2026-06-15T19:59:58.106Z"},{"id":"4005089e-8834-4f42-8760-cb198a55a6d3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-threads-messages-schema","type":"added","scope":"mailbox","summary":"Schema for mailbox_threads + mailbox_messages + mailbox_folders + mailbox_labels — what the M3 sync worker writes to.","body":"M3 first commit from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Adds the\nhigher-altitude tables the sync engine writes to during bootstrap +\nincremental sync. The M3 sync worker that consumes Gmail / Graph\n`bootstrap()` and writes these rows lands in the next commit.\n\n**Four new tables:**\n\n- **`mailbox_folders`** — provider-side folders / IMAP mailboxes.\n  Carries RFC 6154 attributes (`\\Inbox`, `\\Sent`, etc.), display\n  path for tree rendering, message + unread counts. Unique\n  per-`(account_id, provider_folder_id)`.\n- **`mailbox_labels`** — Gmail-style tags. Color, system / custom\n  flag, `source` enum (`provider` = round-trips to Gmail; `local`\n  = Helios-only — used by IMAP accounts that don't support labels\n  natively).\n- **`mailbox_threads`** — conversation primitive. Computed from\n  JWZ-style threading at ingest time (References + In-Reply-To +\n  normalized subject + participants); not trusted from the\n  provider's `threadId` field alone (Gmail's threadId differs\n  across accounts for the same logical conversation). Carries\n  participants list, first / last message timestamps, message\n  count, has_unread / has_starred / has_attachments aggregates,\n  snooze cursor, system category, assignment for shared inboxes.\n- **`mailbox_messages`** — one row per RFC 5322 message. Bodies +\n  attachment bytes live in object storage (`packages/storage`)\n  and are addressable via `body_html_storage_key` /\n  `body_text_storage_key` / per-attachment `storage_key` in\n  `attachments_json`. Postgres holds metadata + the 250-char\n  snippet only. Idempotent on `(account_id, provider_message_id)`\n  so the sync engine's retry loop is a no-op on repeated runs.\n  SPF / DKIM / DMARC verdicts, headers, reply-to references,\n  status enum, outbound-message round-trip pointer all live here.\n\n**Storage strategy:**\n\n- **Bodies** (HTML + plain text + reply-stripped text for AI\n  embeddings) live in object storage at\n  `mailbox/<orgId>/<accountId>/<messageId>/<kind>.{html,txt}` —\n  same shape spec from `MAILBOX_BUILD_PLAN.md` §3 M3.\n- **Attachments** at\n  `mailbox/<orgId>/<accountId>/<messageId>/attachments/<filename>`.\n- **Why bodies aren't in Postgres:** a 10 MB attachment × 100k\n  messages × 10k tenants = 10 PB. Postgres for envelopes;\n  S3-compatible for bytes. Same model as the email module's\n  outbound attachments.\n\n**Indexes:**\n\n- `mailbox_messages (account_id, received_at)` — for the M5\n  inbox-list virtualised reader.\n- `mailbox_messages (status, sent_at) WHERE status='sent'` — for\n  the Sent folder.\n- `mailbox_messages (message_id_header)` — for the\n  `email.outbound.send` round-trip lookup.\n- `mailbox_threads (account_id, last_message_at)` — for the\n  thread list.\n- `mailbox_threads (snooze_until) WHERE snooze_until IS NOT NULL` —\n  for the snooze-wake cron in M9.\n\n**FK + ownership:**\n\n- Every row carries `account_id` with `ON DELETE CASCADE` — when\n  a mailbox account is hard-deleted (rare; soft-delete is the\n  norm), threads / messages / folders / labels cascade.\n- `org_id` is nullable, mirroring `mailbox_accounts` — Q12\n  personal-roaming. The personal-vs-business scope is enforced\n  upstream at the account row; threads + messages inherit it\n  transitively.\n\n**Migration `0267_0268_mailbox_threads_messages.sql`** —\nnon-destructive, idempotent (`IF NOT EXISTS` on every\n`CREATE`). Applies cleanly via the PGlite test harness.\n\nAll 19 existing `packages/db` tests still pass (14 mailbox schema\nCHECK-constraint tests + 5 harness tests).\n\nNext: M3-next — the sync worker cron that picks up\n`mailbox_accounts.status='active' AND last_synced_at IS NULL`,\nlocks with `FOR UPDATE SKIP LOCKED`, loads the provider, decrypts\nthe OAuth tokens via `@helios/email`'s envelope, calls\n`provider.bootstrap()`, walks the async iterable, threads JWZ-\nstyle, and writes the resulting rows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:28.763Z","updatedAt":"2026-06-15T17:29:28.763Z"},{"id":"6869b8a5-a464-4ca5-8fd1-1819800648c0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-vote-plan-gate-and-recompute","type":"added","scope":"roadmap","summary":"votePlanGate now actually gates voting; new admin action retroactively re-weights every vote.","body":"Two long-standing settings fields finally do what they advertise:\n\n- `votePlanGate` (configurable from `/saas/roadmap` settings) now restricts both first-party votes and admin-captured customer votes to subscriptions on the allow list. Empty array = no restriction (the prior behaviour). Returns `quota_exceeded` when a voter's plan is off the list, so the UI can prompt for an upgrade rather than a generic error. Unvotes are not gated — a user who downgraded should still be able to remove a vote they already cast.\n- `platform.roadmap.vote.recompute_weights` is a new root-only action that re-resolves every vote's stored weight against the current `planTierWeights` map and refreshes every feature's `weighted_vote_count`. Use it after editing the weights or a tenant plan upgrade so the kanban's \"demand\" column reflects the new mapping immediately. Supports `dryRun: true` to preview the diff before committing.\n\nAudit findings **A8** and **A12** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:58.200Z","updatedAt":"2026-06-15T19:59:58.200Z"},{"id":"be38d38e-e103-425b-bc9d-18e00e506dde","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-category-picker","type":"added","scope":"support","summary":"Agents can set a ticket's category from the detail sidebar.","body":"The ticket detail sidebar already let agents change status, priority, and\ntype, but not category — even though `support.ticket.change_category` and\nper-org categories existed. `support.config.list` now returns the org's\ncategories, and the sidebar shows a category picker (with a \"No category\"\noption) whenever any are configured, wired to the change-category action.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T21:01:17.914Z","updatedAt":"2026-06-15T21:01:17.914Z"},{"id":"c7f3df69-c816-45e5-9785-1105cc204c88","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-sync-bootstrap","type":"added","scope":"mailbox","summary":"Threading helper + bootstrap orchestrator — walks provider.bootstrap() and writes mailbox_threads + mailbox_messages.","body":"M3-b from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M3. Adds the\n**actual sync logic** — the function that consumes a\n`MailboxProvider.bootstrap()` async generator and turns its\n`RawMessage` pages into rows in the mailbox tables. The M3 worker\ncron that invokes this lands in the next commit.\n\n**`modules/mailbox/src/lib/threading.ts`** — thread reconciliation:\n\n- `normalizeSubject(subject)` — strips reply / forward prefixes\n  (case-insensitive, handles localised variants: Re / Aw / Antw /\n  Sv / Vs / Tr / Fwd / Fw, plus `Re[2]:`-style counts). Collapses\n  whitespace + lowercases.\n- `buildParticipantsList(message)` — dedupes by lower-cased email\n  while preserving the first-seen role.\n- `findOrCreateThread({db, accountId, orgId, message})` — the JWZ-\n  style reconciliation scaled to what providers actually deliver:\n  1. **Provider thread id** — Gmail's `threadId` / Graph's\n     `conversationId` is reliable within a single account.\n  2. **Message-ID reference** — does this message's References /\n     In-Reply-To match any existing message's `message_id_header`\n     in this account? Catches IMAP (no provider thread id) +\n     cross-account reconciliation.\n  3. **Normalized subject + shared participant + ≤30 days** —\n     conservative fallback. Requires BOTH a subject match AND\n     `participants_json @> [{email: from.email}]` so common\n     subjects like \"Re: hi\" don't bleed.\n  4. **Create new thread** otherwise.\n\nPer-account scope on every lookup — no cross-account thread\nmatching (Gmail's threadId differs across accounts; subject-match\nacross accounts would produce noisy bleeding).\n\n**`modules/mailbox/src/lib/sync-bootstrap.ts`** — orchestrator:\n\n- `runBootstrap({db, provider, account, sinceDays?, snippetMaxChars?})`\n  walks `provider.bootstrap(account, {sinceDays})`. For each yielded\n  page → for each `RawMessage`:\n  - **Idempotency check** on `(account_id, provider_message_id)` —\n    repeated bootstraps (worker restart mid-pass) skip duplicates\n    silently.\n  - **Thread reconciliation** via `findOrCreateThread`.\n  - **Insert** the message row with snippet (truncated to\n    `snippetMaxChars`, default 250), envelope fields, references,\n    headers, SPF / DKIM / DMARC verdicts.\n  - **Update thread aggregates** — `message_count`,\n    `last_message_at` / `first_message_at` extremes, `has_unread` /\n    `has_starred` / `has_attachments` boolean ORs.\n  - **Merge participants** into the thread's `participants_json`\n    (dedupe by lower-cased email; first-seen role wins).\n- After the iterator completes, **updates `mailbox_accounts.last_synced_at`\n  + `sync_state_json`** with the final watermark.\n- **Bodies + attachments are NOT written yet** — `body_*_storage_key`\n  are NULL. The storage-write integration lands in M3-c.\n- Per-message errors are swallowed + counted in `stats.errors` so a\n  single bad message doesn't poison the whole bootstrap; the worker\n  caller logs the cause.\n- Returns `BootstrapStats` — `{messagesIngested, threadsCreated,\n  messagesSkippedDuplicate, pagesProcessed, errors, finalWatermark}`.\n\n**Test coverage:** 21 new PGlite-backed tests (92 total in\nmailbox-module):\n\n- `threading.test.ts` (16):\n  - `normalizeSubject` — Re / Fwd / nested / Re[2]: / localised /\n    whitespace / no-strip on embedded \"re:\".\n  - `buildParticipantsList` — role preservation + dedup +\n    empty-email skip.\n  - `findOrCreateThread` — provider thread id match, Message-ID\n    reference match (cross-account / IMAP case), subject + shared-\n    participant match within 30 days, no subject match across\n    accounts, no subject match beyond 30 days, create-new fallback.\n\n- `sync-bootstrap.test.ts` (5):\n  - Multi-page bootstrap ingests messages + creates threads +\n    updates aggregates + persists final watermark + account row.\n  - Idempotent rerun: second invocation skips all messages as\n    duplicates; total row count stays 1.\n  - Snippet truncates to `snippetMaxChars` with `…` suffix.\n  - From / to / cc / bcc / replyTo all persist correctly.\n  - SPF / DKIM / DMARC verdicts captured.\n\nNext: **M3-c** — the worker cron (`apps/worker/src/mailbox-sync-cron.ts`)\nthat claims `mailbox_accounts.status='active' AND last_synced_at IS NULL`\nwith `FOR UPDATE SKIP LOCKED`, loads the GmailProvider /\nGraphProvider from the runtime registry, decrypts the OAuth tokens\nvia `@helios/email.decryptValue`, and invokes `runBootstrap`.\n\nAfter M3-c, a freshly-connected mailbox automatically populates\nwithin minutes — the first user-visible MAIL CONTENT.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:28.782Z","updatedAt":"2026-06-15T17:29:28.782Z"},{"id":"d2a14eb4-40e5-455a-bec5-fd2a848c78f3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-watch-renewal","type":"added","scope":"mailbox","summary":"Watch-renewal cron renews Gmail Pub/Sub (7d TTL) + Microsoft Graph (3d TTL) subscriptions 24h ahead of expiry.","body":"M4 slice 2 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3.\nPrerequisite for slice 3 (webhook receivers) — push\nsubscriptions must be kept alive or notifications stop arriving\nsilently.\n\n## Runner — `runWatchRenewal` (modules/mailbox/src/lib/watch-renewal.ts)\n\nPer account:\n1. Calls `provider.renewWatch(account)`.\n2. On success: persists `watch_subscription_id` + `watch_expires_at`.\n3. On `null` return (provider opts out — e.g. IMAP without IDLE):\n   clears both columns so the claim stops picking the row up.\n4. On `ProviderError(auth_expired)`: flips\n   `status='auth_expired'` (terminal until reconnect).\n5. On any other error: records `last_error`; existing\n   subscription columns stay intact so the next tick retries.\n\n## Claim job — `runWatchRenewalClaim` (modules/mailbox/src/jobs/watch-renewal-claim.ts)\n\nSelects:\n```\nWHERE status = 'active'\n  AND deleted_at IS NULL\n  AND watch_expires_at IS NOT NULL\n  AND watch_expires_at <= now() + renewal_window\n```\nDefault `renewal_window` = 24h. Aggressive enough to absorb\nprovider clock skew + a handful of transient failures; if\nthose eat the window, the polling cron keeps the inbox fresh.\n\nNo `FOR UPDATE SKIP LOCKED` — renewals are idempotent at the\nprovider (Gmail returns the same `historyId`; Graph returns\nthe same subscription id when re-armed with the same\n`notificationUrl + resource + changeType`). Lock-free avoids\ncontention with the high-frequency incremental cron.\n\n## Worker cron — `startMailboxWatchRenewalCron` (apps/worker/src/mailbox-watch-renewal-cron.ts)\n\n30-minute ticks, 60s initial delay (after worker boot\nregisters providers). Re-entrancy guard prevents stacked\nticks. Wired into apps/worker/src/index.ts alongside the\nbootstrap + incremental crons.\n\n## Test coverage (+6, 165 total mailbox-module tests)\n\n`watch-renewal.test.ts`:\n- happy renew → persists new id + expiry\n- provider returns null → clears both columns\n- `auth_expired` → status flip + lastError; subscription\n  untouched so a reconnect can re-arm cleanly\n- generic error → message recorded; subscription columns\n  untouched for next-tick retry\n- claim — renews accounts inside the 24h window; skips\n  outside-window + watch-less rows\n- claim — provider-not-registered records error\n\n## Net behavior\n\nOnce an account's `provider.bootstrap()` registers a push\nsubscription (Gmail watch / Graph `/subscriptions`), the\nrenewal cron keeps it alive ahead of expiry. Combined with\nthe polling fallback from slice 1, the mailbox is now\ndurable in both modes:\n- Push-enabled provider → webhook receivers (slice 3) will\n  trigger sync within seconds.\n- Push-disabled / push-lapsed → 60s poll keeps the inbox\n  fresh.\n\nNext: M4 slice 3 — webhook receivers (Gmail Pub/Sub HTTPS\npush + Graph `changeType=created,updated`) that shortcut\n`next_poll_at = now()` for instant pickup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.876Z","updatedAt":"2026-06-15T19:59:57.876Z"},{"id":"45d83f97-0a31-4c50-af7f-2db0c3e4fae4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-intra-space-dm","type":"added","scope":"chat","summary":"New chat.intra_space_dm.open action lets a client and a staff member DM each other inside their shared Client Space (Phase 4).","body":"Phase 4 of [docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md](../../docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md).\n\nThe DM-hardening pass (`dfff452b`) intentionally gated `chat.dm.open` on\n\"peer is an active member of the actor's org\" — that gate prevents\ncross-org identity/presence leaks for the staff-to-staff DM case but\nblocks the client-portal use case where a client (whose org membership\nis their OWN org, not the staff's) and a staff member want to DM each\nother inside their shared Client Space.\n\nThe fix is a sibling action — not a relaxation of the existing gate.\n`chat.intra_space_dm.open(spaceId, peerUserId)` opens (or resurfaces) a\n1:1 DM channel scoped to a chat space; both actor and peer must be\nmembers of that space, and the resulting `chat_channels` row has\n`space_id` set so it lives inside the space's view rather than the\norg-wide DM list. Slug is `dm-is-<spaceId>-<dmPairKey(a,b)>` —\norder-independent per pair AND distinct from org-wide DMs, so the same\ntwo users can have an org-wide DM AND an intra-space DM without\ncollisions.\n\nAuthorization deliberately does NOT require `chat:dm:create` (which\nclients don't hold). The intra-space-membership check is the gate;\n`chat:channel:read` is the minimum the policy checks for. `chat:admin`\nremains a substitute. Refuses self-DM, returns `not_found` for\nnon-existent spaces or non-member peers (the latter avoids\nuser-enumeration via the space).\n\nWired into `/account/messages`: a new \"+\" button at the end of the\nportal channel strip opens a small popover of OTHER space members.\nClick a member → mutation calls `chat.intra_space_dm.open` → navigate\nto the resulting channel. Idempotent at the user level — picking the\nsame peer twice just re-opens the existing thread.\n\nTests: 7 new cases (deny w/o `chat:channel:read`, refuse self-DM,\nnot_found for missing space, deny non-member actor, not_found for\nnon-member peer, happy path emits one created + two member_added,\nresurface path emits nothing). 267 chat tests pass (was 260).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.366Z","updatedAt":"2026-06-15T22:14:00.366Z"},{"id":"e4b23ab9-f862-433d-a08c-6cad7b492c13","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-canned-replies-composer","type":"added","scope":"support","summary":"Agents can insert a canned reply into the ticket reply composer.","body":"The ticket reply composer now has a \"Canned reply\" picker (agent-only):\nchoosing one renders it for the current ticket — substituting the ticket's\nvariables — and drops the text into the reply body, appending if there's\nalready a draft. It warns when a variable couldn't be filled in. This wires\nup the previously UI-less `support.canned_reply.list` / `.render`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.239Z","updatedAt":"2026-06-15T22:14:01.239Z"},{"id":"28ba8faa-f710-4ce8-8dd8-277154fa3e85","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-incremental-sync","type":"added","scope":"mailbox","summary":"Incremental sync runner + worker cron — bootstrapped mailboxes now pull deltas every 60s via Gmail history.list and Graph delta-query.","body":"M4 slice 1 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. The mailbox\nnow pulls \"what's new\" automatically — previously the bootstrap\npass ran exactly once per account and nothing pulled deltas\nafterwards. Webhook-driven receivers + watch-renewal cron land in\nthe next two slices; the polling-based path shipped here is the\ndurable safety net regardless.\n\n## Runner — `runIncrementalSync` (modules/mailbox/src/lib/sync-incremental.ts)\n\nFor one account per call:\n\n1. Reads the prior watermark from\n   `mailbox_accounts.sync_state_json` (Gmail historyId / Graph\n   deltaLink / IMAP UIDVALIDITY+UIDNEXT / JMAP stateStrings —\n   provider-opaque).\n2. Calls `provider.syncIncremental(account, watermark)`.\n3. If the result's `hint = 'rebootstrap'`: flips\n   `last_synced_at` to NULL + clears `sync_state_json` so the\n   bootstrap claim picks the account up + walks the last 30 days.\n   No ingest this tick. This is the recovery path for Gmail\n   historyId stale beyond 7 days, Graph deltaLink stale, IMAP\n   UIDVALIDITY mismatch, JMAP state cannotCalculateChanges.\n4. Otherwise: ingests each new RawMessage via the shared\n   `ingestOne()` (refactored out of `sync-bootstrap.ts` and now\n   exported so both paths share threading + body-storage logic).\n5. Flips provider-deleted message rows to `status='trashed'`\n   locally.\n6. Records the label-change count for now — the per-message\n   label reconciler against `mailbox_message_labels` lands\n   alongside the M5-b3 sidebar tree.\n7. Persists the new watermark + `last_synced_at = now()` +\n   schedules `next_poll_at` (60s default).\n8. Resets `consecutive_failures` + `last_error` on success.\n\n## Failure handling\n\n- `ProviderError(auth_expired)` → flips status straight to\n  `auth_expired`. No retry until the user reconnects. Surfaced\n  in the sidebar as \"Needs reconnect\".\n- Any other `ProviderError` → bumps `consecutive_failures`;\n  exponential backoff scheduled via `next_poll_at = now + min(60s ×\n  2^N, 30min)`, honoring the provider's Retry-After when given.\n  After 5 consecutive misses, status flips to `failing` and the\n  claim query stops picking it up — admin intervenes manually.\n- Non-`ProviderError` is wrapped as `provider_failed` and goes\n  through the same path.\n\n## Claim job — `runIncrementalSyncClaim` (modules/mailbox/src/jobs/incremental-claim.ts)\n\nAtomic batch claim with the same `FOR UPDATE SKIP LOCKED`\nsemantics as the bootstrap claim. Selects:\n\n```\nWHERE status = 'active'\n  AND last_synced_at IS NOT NULL\n  AND deleted_at IS NULL\n  AND (next_poll_at IS NULL OR next_poll_at <= now())\n```\n\nordered by stalest first. At claim time it pushes `next_poll_at`\n5 minutes ahead as an in-flight marker so a parallel replica\nworker can't double-claim; the runner overwrites it with the\nreal 60s cadence on success (or backs off on failure).\n\nThe bootstrap claim and incremental claim are intentionally\ndisjoint — bootstrap picks `last_synced_at IS NULL`,\nincremental picks `last_synced_at IS NOT NULL`. A failing\nincremental that flips to rebootstrap moves the account back\nto the bootstrap pool on the next tick.\n\n## Worker cron — `startMailboxIncrementalSyncCron` (apps/worker/src/mailbox-incremental-sync-cron.ts)\n\nTicks every 60 seconds (30s initial delay so the worker boot\nfinishes registering providers first). Re-entrancy guard\nprevents a slow tick from stacking the next one. Per-account\nerrors are logged but don't abort the batch — one bad account\nmust not poison the cron.\n\nWired into apps/worker/src/index.ts alongside the existing\nbootstrap cron + counter-cleanup cron.\n\n## Test coverage (159 total mailbox-module tests, +14 new)\n\n`sync-incremental.test.ts` (10 cases):\n- ingests new messages + persists watermark + reset counters\n- empty incremental tick — watermark still updated\n- duplicate suppression (Gmail re-emits across overlapping windows)\n- `hint:'rebootstrap'` flips `last_synced_at = NULL` + clears state\n- provider-deleted messages flip to `status='trashed'`\n- label-change count is recorded\n- `auth_expired` → status flip + rethrow\n- `provider_failed` bumps failures + schedules backoff\n- 5th consecutive failure → `status='failing'`\n- non-`ProviderError` wrapped as `provider_failed`\n\n`incremental-claim.test.ts` (4 cases):\n- claims due accounts + skips future-scheduled, unbootstrapped,\n  and soft-deleted rows\n- updates due account with new watermark + 60s next_poll_at\n- second claim sees no rows because next_poll_at was pushed\n  (multi-replica safety)\n- provider-not-registered records error without clobbering state\n\n## Net behavior\n\nA connected Gmail or Microsoft 365 account that's already\nbootstrapped now picks up new mail within 60s on the polling\npath — no operator intervention. Sent messages from M6\nmaterialise in `mailbox_messages` on the next tick.\n\nNext: M4 slice 2 — watch-renewal runner + cron (renew Gmail\nPub/Sub watches at ~5 days of the 7-day TTL; renew Graph\nsubscriptions at ~70% of their 3-day max TTL). Then slice 3 —\nwebhook receivers (Gmail Pub/Sub HTTPS push + Graph\n`changeType=created,updated` deliveries) which shortcut\n`next_poll_at = now()` for instant pickup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.849Z","updatedAt":"2026-06-15T19:59:57.849Z"},{"id":"cc0a474f-1c2e-49fe-8b13-739df4a38ffe","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-admin-filed-by-filter","type":"added","scope":"roadmap","summary":"Admin triage gets a \"Filed by\" filter that searches every user the platform knows.","body":"The roadmap admin triage queue now has a typed-ahead \"Filed by\" picker in the filter bar. Search by name or email — the picker is cross-tenant via the Phase 8a `platform.roadmap.vote.search_users_admin` action, so admins can isolate every feature a specific customer has filed across the whole roadmap. Combines with every other filter (status / category / search / sort / include-deleted). Audit finding **D4** — closed. **D5** (public-board trending sort) was already shipped in Phase 2a — verified during this pass and recorded as closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:58.018Z","updatedAt":"2026-06-15T19:59:58.018Z"},{"id":"a3d198c6-ea47-4115-b8f7-a9260b6f1e92","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"api-keys-card-extracted","type":"changed","scope":"web","summary":"ApiKeysCard extracted from settings/security.tsx (1438 → 1279 lines).","body":"Pure code-organisation. The API-keys card — mint scoped bearer\ntokens for CLI / AI-agent use, copy-or-lose plaintext on first\ndisplay — moves to `apps/web/src/components/api-keys-card.tsx`\n(175 lines).\n\nSame `iam.user.apiKeys.{list,create,revoke}` calls, same\nbroadcast (`'api-keys'`), same just-minted warning panel.\n\nsecurity.tsx: 1438 → 1279 lines. Net for the three\ncomponent-extract commits this session: 1937 → 1279 lines.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.379Z","updatedAt":"2026-06-15T22:14:00.379Z"},{"id":"01fbd887-679d-4a79-b80b-e10734f209e4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-shared-thread-and-send","type":"added","scope":"mailbox","summary":"Shared-inbox read + mutation + send actions — team members can triage and reply from /mail-style shared mailboxes.","body":"M2-f slice 2 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3.\nSlice 1 shipped the roster; this slice lands the actual\nread + write surface so team members can use a shared inbox\nend-to-end.\n\n## New shared-inbox helper — `loadSharedAccess`\n\n`modules/mailbox/src/lib/shared-access.ts` validates every\n`mailbox.shared.*` call by checking:\n\n1. account exists + not soft-deleted\n2. account.scope === 'shared'\n3. account.org_id === ctx.actor.orgId (cross-org locked out)\n4. actor has an active membership row in\n   `mailbox_account_members`\n\nReturns the actor's role (observer / agent / admin) so the\nhandler can branch mutations:\n\n- **observer** → read only\n- **agent / admin** → read + mutate + send\n\nThe exported `canWriteAsRole(role)` helper centralises the\nrole gate so every mutation action uses the same predicate.\n\n## Actions\n\n### `mailbox.shared.thread.list`\n\nPaginated thread list for a shared inbox the actor is a member\nof. Same shape + folder filter as `mailbox.thread.list`. Gated\nby `mailbox:account:read:shared`.\n\n### `mailbox.shared.thread.get`\n\nReturns the thread + per-message summaries (the bubble stack\nin the reader). Trashed messages excluded. Body URL fetch\nhappens via the same `/api/mailbox/attachment/*` proxy slice 1\nshipped — the action layer doesn't presign anything.\n\n### `mailbox.shared.message.get`\n\nReturns the full envelope for one message — `from`, `to`,\n`cc`, `bcc`, `replyTo`, `subject`, attachments, RFC 5322\nheaders (`messageIdHeader`, `inReplyTo`, `references`).\n\n### `mailbox.shared.thread.mark_read`\n\nFlips `thread.has_unread` AND every message's `is_unread`.\nObservers denied (`policy_denied`).\n\n### `mailbox.shared.thread.star`\n\nToggles `thread.has_starred`. Observers denied.\n\n### `mailbox.shared.thread.archive`\n\nStamps `archived_at = now()` (or clears it when unarchiving).\nThread leaves / re-enters the Inbox view. Observers denied.\n\n### `mailbox.shared.message.send`\n\nCompose + dispatch from a shared inbox. Same pipeline as\n`mailbox.message.send` (M6) but:\n- Ownership runs through `loadSharedAccess` (membership +\n  write role) instead of single-user ownership.\n- The recipient sees the message from the shared inbox's\n  email address (`account.emailAddress` + `account.displayName`).\n- Observers cannot send (`policy_denied`).\n- Inactive account → `dependency_failed`.\n\n`dangerous: true` so the AI runtime requires explicit\nconfirmation. Provider error taxonomy mirrors\n`mailbox.message.send`:\n- `auth_expired` → `validation_failed`\n- `quota_exceeded` → `rate_limited`\n- `network | provider_failed` → `service_unavailable`\n- else → `internal_error`\n\nGated by `mailbox:message:send:shared` (separate from\npersonal/business send permission so admins can grant per-\ninbox).\n\n## Schema tweak\n\n`ThreadListInput.folder` + `ThreadDraftReplyInput.tone` flipped\nfrom `.default()` to `.optional()` so handler-default applies\nwithout forcing the field into the inferred input type. Keeps\nexisting callers (tests, UI) typecheck-clean.\n\n## Test coverage (+24, 229 total mailbox-module tests)\n\n`shared-thread.test.ts` (15 cases):\n- thread.list: member happy path, non-member denied, cross-org\n  denied, folder=archive filter\n- thread.get: includes thread + non-trashed messages\n- message.get: full envelope + non-member denied\n- mark_read / star / archive: agent/admin succeed, observer\n  denied, archive removes from Inbox view\n- policy denial without `mailbox:account:read:shared` or\n  `mailbox:thread:write:shared`\n\n`shared-message-send.test.ts` (9 cases):\n- admin sends; provider receives the shared `from`\n- agent sends; observer denied; non-member denied\n- cross-org denied\n- inactive account → `dependency_failed`\n- personal account through shared path → `validation_failed`\n- `ProviderError(auth_expired)` → `validation_failed`\n- policy denial without `mailbox:message:send:shared`\n\n## Net behavior\n\nA team of agents + observers + admins can now triage + reply\nfrom a shared inbox end-to-end:\n- list threads in the inbox / starred / archive / trash / all\n  folders (same UX as personal mailboxes)\n- read messages + open attachments via the existing proxy\n- mark read, star, archive (write roles only)\n- reply / compose new — the recipient sees `support@acme.com`,\n  not the agent's personal address\n\nObservers see everything but can't mutate — perfect for\nexec / customer / read-only stakeholders.\n\nNext: M2-f slice 3 — the `/settings/mailbox` admin UI for\nprovisioning shared inboxes + managing the roster + an\ninbox-switcher in `/mail` so team members reach shared\ninboxes via the existing keyboard-first chrome.\n\nAfter M2-f: M1g/h IMAP + JMAP providers, then M10b semantic\nsearch across all mail.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.766Z","updatedAt":"2026-06-15T22:14:00.766Z"},{"id":"95fbdf2b-2fba-49f4-bc8f-1e6b53f28603","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-inbound-hmac","type":"added","scope":"forms","summary":"Inbound form endpoints can require an HMAC-SHA256 request signature for the API/webhook path.","body":"A form with inbound enabled can now require signed requests. Set a signing\nsecret (in the \"Signed requests\" section of the Website-integration panel) and\nthe endpoint additionally rejects any POST without a valid `X-Webhook-Signature`\n(hex of `HMAC-SHA256(secret, body)`). Senders may also include a unix-seconds\n`X-Webhook-Timestamp` and sign `<timestamp>.<body>` to bind a replay window\n(default 5 minutes). The token still gates the endpoint; signing layers\nauthenticity on top for the API / custom-backend path (most WordPress plugins\ndon't sign, so it stays opt-in). Phase D of the website-integration plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.819Z","updatedAt":"2026-06-15T22:14:00.819Z"},{"id":"175c84e3-7e8f-4f15-b036-62d919fecd28","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-webhook-receivers","type":"added","scope":"mailbox","summary":"Webhook receivers for Gmail Pub/Sub + Microsoft Graph subscriptions shortcut the polling cron so new mail lands within seconds.","body":"M4 slice 3 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3. Combined\nwith slice 1 (incremental sync runner + cron) and slice 2\n(watch-renewal), the mailbox now has the full push + poll fabric\nproduction needs.\n\n## Endpoints — `apps/web/src/server/mailbox-webhooks.ts`\n\nTwo routes, wired into `apps/web/src/server/prod.ts`:\n\n- **`POST /api/mailbox/webhook/gmail/<token>`** — Pub/Sub push.\n  Decodes the base64 `message.data` payload, resolves the\n  account by `email_address` (case-insensitive), sets\n  `next_poll_at = now()` so the incremental cron picks it up\n  on its next tick (≤60s, typically ~30s).\n\n- **`POST /api/mailbox/webhook/graph/<token>`** — Graph\n  notification batch. For each value entry: verifies the\n  `clientState` echo against `MAILBOX_GRAPH_CLIENT_STATE`\n  (when set), resolves the account by\n  `watch_subscription_id`, sets `next_poll_at = now()`.\n\n- **`GET /api/mailbox/webhook/graph/<token>?validationToken=…`**\n  — Graph's subscription-create handshake. Responds 200 with\n  the token as `text/plain` per Microsoft's spec.\n\n## Authentication\n\n- **Unguessable URL-path token.** New env `MAILBOX_WEBHOOK_TOKEN`\n  (min 16 chars). Both Gmail + Graph register their notification\n  URL with this segment. Constant-time compare on every\n  request; mismatch → 403. Misconfiguration (missing or too\n  short) → 500 so the operator sees it in logs.\n\n- **Graph `clientState` echo.** Optional second layer for\n  Graph. `MAILBOX_GRAPH_CLIENT_STATE` (min 16 chars) is the\n  opaque value we set at subscription create time; Graph\n  round-trips it on every notification. Mismatched entries\n  are skipped silently.\n\n- TODO follow-up: full Google OIDC Bearer JWT verification\n  (JWKS against\n  `https://www.googleapis.com/oauth2/v3/certs`, audience =\n  registered URL). The path token + clientState pattern is\n  the same model Stripe + Postmark use successfully; JWT is\n  the planned hardening.\n\n## Behavior on miss\n\n- **Bad JSON / malformed envelope** → 200 (don't trigger\n  retry storms on garbage input).\n- **No matching account** → log + 200 (a renamed or\n  disconnected mailbox shouldn't break the receiver).\n- **Handler throws** → log + 200 (Pub/Sub + Graph retry\n  indefinitely on non-2xx; we'd rather absorb a single\n  failure than get drowned).\n\nThe polling cron is the safety net regardless — if a\nnotification never arrives, the 60s poll keeps the inbox\nfresh.\n\n## Test coverage (+15)\n\n`apps/web/src/server/mailbox-webhooks.test.ts`:\n- `parseGmailNotification` — valid envelope, malformed JSON,\n  missing `message.data`, missing inner `emailAddress`\n- `parseGraphSubscriptionIds` — distinct extraction with no\n  clientState; clientState mismatch filter; empty + malformed\n  fallthrough\n- `pokeGmailAccount` — sets `next_poll_at = now()`;\n  case-insensitive emailAddress; skips inactive; empty for\n  unknown email\n- `pokeGraphAccounts` — sets `next_poll_at = now()` on matched\n  rows; empty on no-match + empty input\n\n## Net behavior\n\nOnce `MAILBOX_WEBHOOK_TOKEN` (and optionally\n`MAILBOX_GRAPH_CLIENT_STATE`) are configured AND the\nmailbox-sync runtime registers a Gmail watch / Graph\nsubscription pointed at this URL, new mail at Gmail/Outlook\ntriggers a `next_poll_at = now()` write within seconds; the\nincremental cron picks it up on the next 60s tick. Combined\nwith watch-renewal (slice 2), the push surface stays alive\nindefinitely.\n\nNext on the M-queue: M5-b3 — Cmd+K command palette +\nfolder/label sidebar tree + attachment download presign;\nthen M2-f shared inbox + admin business provisioning, M1g/h\nIMAP/JMAP providers, M10+ AI panel.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:57.933Z","updatedAt":"2026-06-15T19:59:57.933Z"},{"id":"a703e0e0-dc28-45e0-b1d9-e2f6c80a9358","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-search","type":"added","scope":"mailbox","summary":"Search across an entire mailbox — type in the middle pane to filter threads by subject, snippet, or sender. Works on personal, business, and shared.","body":"The ⌘K palette has always searched threads in the current view;\nthis commit adds the missing piece — cross-folder, cross-message\nsearch of an entire mailbox. Without it, the inbox became\nunusable past a few hundred messages.\n\n## Action surface\n\nBoth `mailbox.thread.list` and `mailbox.shared.thread.list` gain\nan optional `search: string` parameter (min 2 chars, max 200).\nWhen set:\n\n- The folder filter is **relaxed to `all`** (excluding trash)\n  so the user doesn't have to switch folders to find a needle\n  that happens to live in Archive. This matches Gmail and\n  Shortwave. Pass `folder: 'trash'` explicitly to search trash.\n- Matching is **case-insensitive substring** (`ILIKE`).\n- Matches:\n  - `mailbox_threads.subject_normalized`\n  - any non-trashed message in the thread:\n    `subject`, `snippet`, `from_email`, or `from_name`.\n- Wildcard characters in the user's query (`%`, `_`, `\\`) are\n  backslash-escaped before being interpolated into the pattern,\n  so an injection attempt resolves to a literal match.\n\nThe existing `unreadOnly`, `cursor`, and `limit` parameters\ncontinue to apply, so search composes with pagination + read\nstate filters.\n\n## UI in `/mail`\n\nThe middle pane's header was redesigned around a\n`ThreadSearchBox`:\n\n- Compact pill at top-right (folder label sits on the left).\n- Magnifying-glass icon + transparent input.\n- Typing for 250ms (debounced) triggers the search. Below 2\n  chars sends no `search` parameter — the list reverts to the\n  folder view.\n- An `×` button (and the `Esc` key inside the input) clears the\n  search instantly.\n- A subheader strip shows `N matches across all folders` when\n  searching, vs `N threads` when browsing.\n- Empty-state copy switches to \"Nothing matches '{query}'\" with\n  a hint that search runs across subject, snippet, and sender.\n\nState auto-resets when the user switches accounts (you don't\ninherit Alice's search query when you open the team inbox).\n\n## Test coverage (+8, 248 total mailbox-module tests)\n\n`read.test.ts` (7 new cases):\n- search matches subject substring + relaxes folder to All (a\n  thread that's been archived still matches)\n- search matches per-message snippet\n- search matches sender name + email\n- search is case-insensitive\n- search returns empty for a non-matching needle\n- search excludes trash by default\n- search inside the Trash folder explicitly returns trashed\n  matches\n\n`shared-thread.test.ts` (1 new case): same subject+folder-relax\ntest on the shared path so behaviour stays in lockstep.\n\n## Net behavior\n\nType a sender's name → every thread they've ever written to you\nacross Inbox, Archive, Starred surfaces in 250ms. Type a phrase\nfrom a snippet → same. Hit Esc → back to the folder view. The\nexisting `⌘K` palette keeps its role as a quick-jumper within\nthe visible thread set; the new search bar is for cross-mailbox\ndiscovery.\n\nCombined with the prior slices this turn, every mailbox surface\n(personal, business, shared) now has:\n- folder navigation\n- keyboard-first navigation + ⌘K palette\n- attachments download\n- AI summary + draft reply (shared respects observer role)\n- full-mailbox search\n\nRemaining mailbox queue: M1g IMAP + M1h JMAP providers; M10b\nsemantic search across all mail; misc polish.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.563Z","updatedAt":"2026-06-15T22:14:00.563Z"},{"id":"030e8b7f-08cb-41a2-aea3-3225173c3997","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-inbound-delivery-log","type":"added","scope":"forms","summary":"The form editor now shows a recent-inbound-activity log of website/webhook delivery attempts.","body":"Inbound delivery attempts that reach a form (via a website form plugin, webhook,\nor API) are now recorded and surfaced in the editor's \"Recent inbound activity\"\nlist — delivered submissions plus post-authentication failures (bad signature,\nfailed validation) with their status, mapped/raw field counts, and timestamp.\nOnly requests that present the correct token are logged, so the log can't be\nflooded by unauthenticated traffic. Backed by the new read-only\n`forms.inbound.log` action. Phase C of the website-integration plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.832Z","updatedAt":"2026-06-15T22:14:00.832Z"},{"id":"c4abc5ff-49fe-486c-8730-daedc2ed5982","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-account-portal-tile","type":"added","scope":"chat","summary":"Client portal /account home now surfaces a chat tile with unread badge and CTA into the conversation.","body":"Phase 1 of [docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md](../../docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md).\n\nThe client portal had **zero** chat surface even though the backend had been\nwiring up Client Spaces all session (a client added by staff to a\n`kind='client'` space IS a `chat_space_members` row and CAN read/post via\ntheir `EXTERNAL_CHAT` permission set). Clients had no way to discover the\nconversation from their portal — only via a chat-notification email that\ndeep-linked into the staff-facing `/chat/*` UI, which is the wrong mental\nmodel for the recipient.\n\n`/account/` now renders an **AccountChatTile** between the AI brief and the\nstat-card grid:\n\n- **Has a client space + unread:** \"{N} new mentions · {N} unread messages\"\n  + an \"Open messages\" CTA tinted with the chat-module accent.\n- **Has a client space + caught up:** \"You're all caught up.\" with a calm\n  \"View chat\" CTA.\n- **No client space yet (staff hasn't opened one):** quiet \"Your account\n  team hasn't opened a chat with you yet. For a faster reply, file a\n  support ticket.\" with an \"Open support\" CTA. Decision D-B from the plan\n  — clients don't initiate Client Spaces; staff does.\n- **Permission denied / lookup fails:** silently renders nothing. Better\n  no card than an error card on the portal home.\n\nThe CTA points at `/chat/$spaceSlug/$channelId` for now — Phase 2 of the\nplan introduces a portal-shaped `/account/messages` view that wears the\nclient portal chrome; until then the legacy chat route works fine.\n\nReads from `chat.space.list` + `chat.channel.list` (60s / 30s stale times)\nso the badge stays current across portal navigation without a heavy poll.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.098Z","updatedAt":"2026-06-15T18:29:12.098Z"},{"id":"24c7d8f7-ec98-4045-9b3a-0c27bc4c5b3e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-trending-cron","type":"added","scope":"roadmap","summary":"Trending score now recomputes every 5 minutes with proper half-life decay.","body":"The roadmap trending sort finally tells the truth. A new worker cron re-aggregates every feature's `trending_score` from the votes table every 5 minutes using a 14-day half-life decay — older votes contribute exponentially less, so a quiet 100-vote feature can be overtaken by a fresh wave of 30 enthusiastic upvotes. Combined with Phase 8b vote weights, the trending board now distinguishes between equal-vote-count features by the demand they actually represent. Half-life is configurable via `ROADMAP_TRENDING_HALF_LIFE_DAYS`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.369Z","updatedAt":"2026-06-15T18:29:12.369Z"},{"id":"5edc9227-d6a1-49c3-8521-a0fdd44bec29","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-client-redirect","type":"changed","scope":"chat","summary":"Client portal users landing on /chat/* are now redirected to /account so they never see the staff sidebar.","body":"Phase 3 (lightweight client-side variant) of\n[docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md](../../docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md).\n\nA client portal user (`users.type === 'client'`) clicking a stored chat\nnotification href — or any deep link into `/chat/*` — used to land in the\nfull staff-facing Slack-style sidebar UI. Wrong mental model: clients\nbelong on `/account/*`. The `/chat/*` parent route now sniffs the actor's\ntype and bounces clients out to `/account` (where the Phase 1 chat tile\ngives them the entry point).\n\nMirrors the inverse redirect that `/account/*` already does for non-client\nidentities: clients on `/account/` stay, non-clients get sent back to `/`.\n\nStopgap: the actual fix is server-side — when a chat notification is\ncreated with `users.type === 'client'` as the recipient, the stored `href`\nshould already point at `/account/...` and never at `/chat/*`. That's the\n\"real\" Phase 3 (server-side notification dispatcher) and lands once\n`/account/messages` (Phase 2) exists as a deep-link target.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.449Z","updatedAt":"2026-06-15T18:29:12.449Z"},{"id":"0a32f822-4727-423b-933d-9d938231a8d9","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-shared-ai-panel","type":"added","scope":"mailbox","summary":"Shared inboxes get the AI panel — summarise threads + draft replies, with observers limited to summary-only.","body":"Closes the AI gap left by M2-f slice 3, where the AiPanel was\nexplicitly hidden on shared inboxes pending shared-scope AI\nactions. Team members now get the same AI assistance personal\nmailbox users have had since M10.\n\n## New helper — `modules/mailbox/src/lib/ai-thread.ts`\n\nExtracted the prompt construction + AI call + JSON parsing from\nthe personal handlers into two reusable functions:\n\n- `summariseThreadWithAi({threadSubject, messages, modelOverride?})`\n  → `{summary, actionItems[], model}` or a structured error.\n- `draftReplyWithAi({threadSubject, readerEmail, intent?, tone?,\n  messages, modelOverride?})` → `{subject, body, model}` or a\n  structured error.\n\nBoth functions take a pre-loaded `messages: MessageForAi[]` so\nthey don't touch the DB — the calling action runs whatever\nownership query it needs, then hands the messages over. The\nerror type is mapped via `aiErrorToCode()` so callers can return\nthe right ActionErrorCode without duplicating the logic.\n\nThis kills the copy-paste between personal and shared AI\nhandlers: there is now ONE prompt for summarise and ONE for\ndraft-reply. Prompt drift is impossible.\n\n## New actions\n\n### `mailbox.shared.thread.summarize`\n\n- Input: `{threadId, model?}`. Output: `{threadId, summary,\n  actionItems[], model}`.\n- Loads the thread + members via `loadSharedAiContext()` which\n  wraps `loadSharedAccess()` and projects the message rows.\n- Any member role (observer / agent / admin) can summarise —\n  it's a read operation.\n- Gated by `mailbox:ai:use:shared`.\n\n### `mailbox.shared.thread.draft_reply`\n\n- Input: `{threadId, intent?, tone?, model?}`. Output:\n  `{threadId, subject, body, model}`.\n- Same ownership + AI pipeline as summarize, plus an additional\n  observer-vs-agent gate via `canWriteAsRole()` — observers see\n  the summary but cannot draft (effective write, since the draft\n  is the one-click path to a Send).\n- `From: {sharedInbox.emailAddress}` is grounded in the prompt\n  so the AI writes in the inbox's voice, not the agent's.\n- Gated by `mailbox:ai:use:shared`.\n\n## Personal handlers refactored\n\n`mailbox.thread.summarize` and `mailbox.thread.draft_reply` now\ncall into the same helper. Roughly 100 lines per handler\ndeleted; behaviour preserved (all 17 existing AI tests still\ngreen).\n\n## UI in `/mail`\n\nThe AiPanel no longer hard-hides on shared inboxes. The reader\npane now passes:\n\n- `summarizeAction` — the right action name from `actionSet`.\n- `draftReplyAction` — same.\n- `disableDraft` — true when the actor is a shared-inbox\n  observer. Hides the \"Draft reply with AI\" surface; summary\n  remains. Server-side rejection is still the source of truth.\n\nThe action-set router in `mail.tsx` was extended with the two\nAI action names so picking between personal / shared paths\nstays in one place.\n\n## Test coverage (+11, 240 total mailbox-module tests)\n\n`shared-thread-ai.test.ts`:\n- observer can summarise (read-only privilege)\n- agent can summarise\n- non-member denied\n- cross-org denied\n- AI runtime missing → `dependency_failed`\n- summarize policy denial without `mailbox:ai:use:shared`\n- agent can draft a reply (prompt carries `support@acme.com`\n  as the reader email)\n- **observer CANNOT draft a reply** (with the specific\n  \"Observers\" error message)\n- non-member denied on draft\n- not_found on unknown thread\n- draft_reply policy denial without permission\n\nThe existing personal AI tests (`thread-summarize.test.ts` +\n`thread-draft-reply.test.ts`) continue to pass with the new\nshared helper underneath them.\n\n## Net behavior\n\nA customer-support team in a shared inbox now gets full AI\nassistance:\n- Any member: open a thread → click `Summarise with AI` → read\n  the 1-3 sentence summary + action items in seconds.\n- Agents + admins: type intent → click `Draft reply with AI` →\n  compose dock opens pre-filled with a draft in the shared\n  inbox's voice → review + send.\n- Observers see the summary, no draft surface — server-side\n  reject if they bypass the UI guard.\n\nThis brings shared inboxes to AI parity with personal +\nbusiness mailboxes.\n\nNext on the mailbox queue: M1g IMAP + M1h JMAP providers\n(opens the mailbox to non-Gmail / non-Microsoft providers),\nthen M10b semantic search across all mail.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.578Z","updatedAt":"2026-06-15T22:14:00.578Z"},{"id":"cf95ad52-625d-4d23-b235-ab5e1ac5b924","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-account-messages-route","type":"added","scope":"chat","summary":"New /account/messages route — portal-shaped chat view for clients (Phase 2).","body":"Phase 2 of [docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md](../../docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md).\n\nAdds `/account/messages` — a portal-shaped chat surface scoped to the\nclient's single Client Space. The full message rail, composer, threads,\nreactions, mentions, attachments, typing indicator and unread bookkeeping\nall come through unchanged because the page reuses the existing\n`<ChannelView>` component verbatim; the difference is purely chrome:\n\n- **No `<SpaceMenu>`** — a client belongs to exactly one Client Space\n  (the `(org_id, client_id)` unique index enforces it), so the switcher\n  is meaningless.\n- **No staff `/chat/*` top-bar + sidebar** — the page lives inside the\n  `/account` `PortalShell`, so the client portal navigation stays in\n  place.\n- **Slim channel strip** — when a space has more than one channel\n  (rare; most clients see only `#general`), a single thin row of pill\n  tabs lets them switch. The strip collapses out for single-channel\n  spaces.\n\nURL contract: `/account/messages?channel=<id>`. The Phase 3 server-side\nnotification dispatcher (next commit on the path) will populate this\nparam so an emailed link lands directly on the relevant channel.\n\nThe Phase 1 `AccountChatTile`'s \"Open messages\" CTA now points here\ninstead of `/chat/$spaceSlug/$channelId` — one hop instead of the two\nthat the Phase 3 client-side redirect would have produced.\n\nState handling:\n\n- **No space yet** — quiet \"your account team hasn't opened a chat with\n  you yet\" message + link to support tickets.\n- **No channels in the space** — quiet \"no channels yet\" message.\n- **Loading** — Skeleton chrome that matches the final layout.\n\nThe route's `as never` casts are temporary; they fall away the next time\nthe Vite plugin regenerates `routeTree.gen.ts` on dev/build.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.137Z","updatedAt":"2026-06-15T18:29:12.137Z"},{"id":"8e155841-60a7-4df0-8c57-460161e350bd","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-body-storage","type":"added","scope":"mailbox","summary":"Bootstrap writes body bytes (HTML + plain + reply-stripped) to object storage so the M5 reader can render them.","body":"M3-c followup from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M3. The\nM3-c worker cron synced envelope metadata (snippet, headers,\nsender, recipient, threading) into the DB; this commit wires the\n**body bytes** through to object storage so the M5 reader pane can\nactually display them.\n\n**`modules/mailbox/src/lib/reply-strip.ts`** — pure helper:\n\n- `stripReplyQuotes(body)` drops the most common quoted-reply\n  patterns from a plain-text body:\n  1. \"On <date> <name> wrote:\" sentinel (Gmail / Apple Mail /\n     most clients).\n  2. Outlook's \"From: ... Sent: ... To: ... Subject:\" reply\n     block.\n  3. Trailing run of `>`-quoted lines.\n- Conservative — when no sentinel matches, returns the original.\n  Better to over-include than to silently drop new content.\n\nThe stripped variant is what the M11 AI embedding pass will index\n(embedding entire re-quoted threads wastes vector budget; the\noriginal is preserved for the reader pane).\n\n**`modules/mailbox/src/lib/sync-bootstrap.ts`** — accepts an\noptional `storageClient`:\n\n- When provided, body HTML / plain text / reply-stripped variants\n  are written to object storage; the resulting keys are stamped on\n  the `mailbox_messages` row.\n- When omitted, storage keys stay NULL — the list view still\n  works via the snippet column; the reader pane shows \"body not\n  yet synced\" until storage comes back online.\n- **Key shape:** `mailbox/<orgId-or-_personal>/<accountId>/<providerMessageId>/<kind>`.\n  Personal-scope accounts use the `_personal` sentinel for the\n  org segment so the prefix tree stays shallow.\n- **Optimization** — when the reply-stripped variant equals the\n  raw text (no quoted reply was found), the stripped storage key\n  aliases the raw text key instead of writing the same blob twice.\n- 4 storage calls max per message (`body.html` + `body.txt` +\n  `body.stripped.txt`); 0 when both bodies are absent.\n\n**`modules/mailbox/src/jobs/sync-claim.ts`** — passes\n`storageClient` through to `runBootstrap` so the cron's tick uses\nthe worker-shared client.\n\n**`apps/worker/src/mailbox-sync-cron.ts` + `apps/worker/src/index.ts`** —\nconstructs a `StorageClient` from the standard env variables\n(`STORAGE_ENDPOINT` / `STORAGE_BUCKET` / etc., same as the existing\nraw-MIME archiver) and passes it to `startMailboxSyncCron`. Same\nbucket as the existing email-module archiver.\n\n**Test coverage:** 9 new tests (108 total in mailbox-module):\n\n- `reply-strip` (6) — \"On ... wrote:\" sentinel, Outlook From: block,\n  trailing `>` run, no-sentinel pass-through, empty input,\n  combined sentinels.\n- `sync-bootstrap` (3) — writes body bytes to storage when client\n  provided (with content-type assertions); keys stay NULL when\n  client omitted; reply-stripped blob lands as a separate key\n  when quoted content is present.\n\n**Net behavior after this commit:** A user connects Gmail → the\nbootstrap cron runs within ~30s → mailbox_threads + mailbox_messages\npopulate AND body bytes land in object storage → the M5 reader\n(when it ships) can render them by presigning the storage key.\n\n`@helios/storage` added to `modules/mailbox/package.json` deps.\n\nNext: **M5** — the `/mail` UI (three-pane reader). The data layer\nis now complete enough that M5 has everything it needs (envelope +\nthreading in Postgres; bodies in S3-compatible storage).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.449Z","updatedAt":"2026-06-15T18:29:12.449Z"},{"id":"e3fa893b-f1e7-4146-b897-51b27d1b081f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-mail-ui","type":"added","scope":"mailbox","summary":"/mail UI — polished three-pane mail client with virtualised list, hover prefetch, J/K keyboard nav, sandboxed HTML body.","body":"M5-b from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M5. **The first\nreal mailbox client UI** — the moment the synced data becomes\nsomething a user can actually read.\n\nPolish bar (per user direction this session): Shortwave /\nSuperhuman class. Layout uses Helios's existing `var(--bg-*)` /\n`var(--fg-*)` / `var(--border-*)` tokens so the inbox gets dark\nmode + accent theming for free.\n\n**`apps/web/src/routes/mail.tsx`** — single file route at\n`/mail`. Three-pane CSS grid (`220px` accounts sidebar / `380px`\nthread list / `1fr` reader):\n\n- **Account sidebar** (left, 220px). Provider glyph (G/M/J/I)\n  with active-state tint; email + `BIZ` chip for business\n  scope; per-account sync status (`Needs reconnect`, `Sync\n  failing`, `Synced 5m`, `Awaiting first sync…`). Refresh\n  button in the header. \"Manage accounts\" link to\n  `/settings/mailbox` in the footer.\n- **Thread list** (middle, 380px). Virtualised via\n  `@tanstack/react-virtual` when ≥50 threads; plain list\n  below. Each row: sender names (with truncation), message\n  count for >1, unread dot (semantic-token dot, not generic\n  blue), starred indicator, snippet, attachment paperclip,\n  relative-time stamp. Unread rows render with `font-semibold`\n  on the sender + the snippet stays full-opacity. Hover state\n  highlights with `--bg-subtle`; active state with `--bg-active`.\n- **Thread reader** (right, fills remaining). Header shows\n  subject + thread metadata. Message list per-bubble:\n  collapsed shows From → To with snippet; expanded fetches the\n  body via `mailbox.message.get` and renders. **HTML bodies\n  render in a sandboxed iframe** (`sandbox=\"\"` — blocks\n  scripts / top-navigation / form submit / popups). Plain\n  text renders in a `<pre>` with `whitespace-pre-wrap`.\n\n**Snappiness moves (the Shortwave/Superhuman signature):**\n\n- **Hover prefetch.** Mousing into a thread row prefetches\n  `mailbox.thread.get` with a 30s stale time. When the user\n  clicks, the reader is already populated — feels instant.\n- **Optimistic active selection.** Active row updates\n  immediately on click; no waiting for the query to settle.\n- **Smooth scroll-into-view** for the active row when J/K\n  navigation walks the list past the current viewport.\n\n**Keyboard shortcuts (M5-b1):**\n\n- **J** — next thread\n- **K** — previous thread\n- **Esc** — close the reader (deselect)\n- Ignores key events fired from inputs / textareas /\n  contenteditable so typing in a compose field doesn't\n  navigate. (More shortcuts — E archive, R reply, etc. — land\n  in M5-b2 alongside the mutation actions.)\n\n**Empty + loading states with personality:**\n\n- No accounts: envelope glyph + \"No mailboxes yet\" + inline\n  link to Settings.\n- No threads: open-envelope duotone glyph + \"Inbox zero\" + \"New\n  mail will appear here as it syncs.\"\n- Loading: skeletons that match the actual row shape so the\n  layout doesn't shift on first paint.\n- No thread selected: chat-bubble duotone glyph + \"Select a\n  thread to read\" + inline `<kbd>J</kbd> / <kbd>K</kbd>` hint.\n\n**Nav placement** — adds **Mail** to `PRIMARY_MODULES` in\n`apps/web/src/components/modules.tsx`, between Chat and Support\n(the Communication group). Phosphor `EnvelopeSimple` icon;\n`chat` accent for color parity with the comms cluster.\n\nTypecheck across `apps/web` is clean.\n\n**What this commit does NOT yet have** (lands in M5-b2 + b3):\n\n- Compose / reply / forward (write actions `mailbox.draft.save`\n  + `mailbox.message.send` come in M6).\n- Mark-read / archive / star / snooze (mutation actions in\n  M5-b2).\n- Attachment download (presign endpoint for\n  `mailbox.attachment.download` in M5-b2).\n- Folder / label filtering (sidebar tree component in M5-b3).\n- Command palette integration (Cmd+K).\n- Real-time updates when new mail arrives (M4 incremental sync\n  + a LISTEN/NOTIFY bridge).\n- AI summary / draft reply / semantic search panel (M10+).\n\nEach of those is a follow-up commit; the foundation now exists\nfor them to slot into the polished shell without rework.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:38:59.798Z","updatedAt":"2026-06-15T18:38:59.798Z"},{"id":"9642b3b0-d412-4907-b4d8-e86376d197fb","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-inbound-connect-helper","type":"added","scope":"forms","summary":"The inbound panel now shows a \"How to connect\" guide with a copyable curl command and example payload.","body":"Once a form has inbound enabled, the editor's \"Website integration\" panel gains\na \"How to connect\" section: an example JSON payload built from the form's own\nfield ids, a copyable `curl` command pre-filled with the live URL and secret,\nand short instructions for pointing a WordPress form plugin (or Elementor /\nNinja via `?token=` + form-encoded fields) at the endpoint. Phase C of the\nwebsite-integration plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.591Z","updatedAt":"2026-06-15T22:14:00.591Z"},{"id":"7b202014-10fb-449d-b6a2-18e42fd37a33","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"support-group-membership-team-scope","type":"added","scope":"support","summary":"Support agent groups now have real membership that powers the team ticket scope.","body":"Agents can be added to and removed from support groups via the new\n`support.group.add_member` / `remove_member` / `list_members` actions\n(gated by the existing `support:group:assign_members` permission). Membership\nis the backing for the `support:ticket:read:team` scope: an agent with team\nread now sees — and can open — every ticket assigned to a group they belong\nto, not just their own. Previously team scope silently collapsed to \"own\"\nbecause the membership table was never populated. The inbox list filter and\nthe per-row access guard read the same membership, so they can't diverge into\nan IDOR. Removing a member revokes access immediately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.261Z","updatedAt":"2026-06-15T22:14:01.261Z"},{"id":"9886b943-9e84-49c0-8c2c-73ff2a02b1ca","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"recent-accounts-corner","type":"changed","scope":"web","summary":"Recent-accounts picker moves out of the /login card into a top-right corner pill + popover so the in-card hero stops being a 12-block stack.","body":"The /login card hosts twelve vertical blocks today — recent-\naccounts picker, passkey, five OAuth providers, SAML, LDAP,\npasswordless (magic-link + email-OTP), separator, email +\npassword form, captcha, forgot-password, remember-me, submit.\nAt 3+ remembered accounts the picker pushed the form past the\nviewport on a 13\" laptop.\n\nNew surface:\n\n- `RecentAccountsCorner` pill at the top-right of the form\n  column. Compact \"Continue as <name>\" with the avatar, name,\n  and account-count subtitle. Click → popover with the full\n  remembered list (re-uses `AccountRow` from\n  `recent-accounts.tsx` so the row shape, forget-on-hover,\n  pending spinner, and continue-as semantics stay identical).\n- `AuthLayout` gains a `cornerSlot` prop. `/login` passes the\n  pill via the slot; the in-card `RecentAccountsPicker`\n  invocation is gone. Other AuthLayout consumers (`/signup`,\n  `/forgot-password`, `/reset-password`) leave the slot null —\n  showing \"Continue as Alex\" in a recovery flow would be\n  confusing.\n- Escape + click-outside close the popover. Pill stays hidden\n  for first-time visitors (zero remembered accounts).\n- Mobile (sm-): pill still floats top-right of the viewport\n  since the dark aside is hidden below `lg`.\n\nThe picker is hidden during the TOTP challenge (matches the\nprevious behaviour — the form swaps to a 6-digit code input).\n\nThe legacy `RecentAccountsPicker` export stays in\n`recent-accounts.tsx` for back-compat — no other consumer\nreferences it today, but keeping the export avoids breaking\nanything reading from a stale build cache.\n\nNo schema. UI shape change only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:58.000Z","updatedAt":"2026-06-15T19:59:58.000Z"},{"id":"2318b92b-e129-41b3-abe3-04a69da18225","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-sync-worker","type":"added","scope":"mailbox","summary":"Mailbox bootstrap-sync worker — registers Gmail+Graph providers + 30s cron that claims unsynced accounts.","body":"M3-c from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M3. Wires the\nsync orchestrator from M3-b into the worker boot. After this commit,\na freshly-connected mailbox automatically populates within 30\nseconds — the **first user-visible mail content** in the module.\n\n**`modules/mailbox/src/lib/provider-binding.ts`** — bridge\nbetween the encrypted DB row and what `MailboxProvider` methods\ntake:\n\n- `bindingFromRow(row)` decrypts `oauth_tokens_encrypted` via\n  `@helios/email.decryptValue` and projects the row into a\n  `MailboxAccountBinding` (the minimal shape the sync engine\n  needs).\n- `persistRefreshedTokens(db, {accountId, result})` writes a\n  refreshed token bundle back to the row via\n  `@helios/email.encryptValue`. Critical for Microsoft Graph —\n  it rotates the refresh token on every refresh; failing to\n  persist causes the next refresh to fail with `invalid_grant`.\n\n**`modules/mailbox/src/jobs/sync-claim.ts`** — atomic claim\n+ run:\n\n- `runMailboxSyncClaim({db, batchSize?, providers?, sinceDays?})`\n  uses `UPDATE ... WHERE id IN (SELECT ... FOR UPDATE SKIP LOCKED)`\n  semantics to claim up to `batchSize` accounts where\n  `status='active' AND last_synced_at IS NULL`. Multi-replica\n  workers never double-claim — the SKIP LOCKED keeps them from\n  fighting.\n- Each claimed row: looks up the provider in the registry,\n  decrypts tokens, invokes `runBootstrap`.\n- **Sentinel-claim trick** — the UPDATE writes `'epoch'::timestamptz`\n  to `last_synced_at` BEFORE the actual sync runs. Success\n  overwrites with the real `now()`; failure reverts to `NULL` so\n  the next tick retries.\n- **Consecutive failure tracking** — every failure bumps\n  `consecutive_failures` + records `last_error`. After 5 in a\n  row, the row flips to `status='failing'` so the claim query\n  stops picking it up; admin intervenes manually.\n- Per-account isolation — one bootstrap throwing doesn't poison\n  the batch.\n\n**`modules/mailbox/src/jobs/index.ts`** — runtime registration:\n\n- `registerMailboxJobs({db})` constructs `GmailProvider` +\n  `GraphProvider` with the `persistRefreshedTokens` callback as\n  `onTokenRefreshed`, then calls `setMailboxSyncRuntime({providers})`\n  so the sync claim can look them up by `mailbox_accounts.provider`.\n\n**`apps/worker/src/mailbox-sync-cron.ts`** — the cron:\n\n- Default cadence: **30 seconds**, with a 10s initial delay so\n  worker boot doesn't stack a sync onto migration replay.\n- Default `batchSize`: 5 accounts per tick.\n- **Re-entrancy guard** — a slow bootstrap doesn't stack a second\n  tick on top of the first.\n- Standard worker `AbortSignal` for graceful shutdown.\n- Logs aggregated tick summaries + per-account error lines.\n\n**`apps/worker/src/index.ts`** — calls `startMailboxSyncCron` next\nto the existing `startMailboxCountersCleanupCron`.\n\n**Test coverage:** 7 new PGlite-backed tests (99 total in\nmailbox-module):\n\n- Claims only `status='active' AND last_synced_at IS NULL` rows;\n  skips soft-deleted + already-synced\n- Atomic claim — second call in the same tick claims zero\n- Successful bootstrap stamps `last_synced_at` (real timestamp,\n  not the epoch sentinel) + `sync_state_json`\n- Missing provider → marks failed + increments\n  `consecutive_failures` + reverts `last_synced_at`\n- Provider throws → reverts `last_synced_at` + records error\n- After 5 consecutive failures, row flips to `status='failing'`\n- Respects `batchSize`\n\n**Typecheck across `apps/worker` is clean.**\n\n**This closes M3** — the user can now sign in → Settings →\nMailbox → Connect Gmail / Outlook → consent → wait ~30 seconds →\nsee threads + messages in the DB. The /mail UI that renders\nthem (three-pane reader, virtualised list, thread view) lands\nin M5. Body bytes write to object storage in M3-c-followup.\nIncremental sync via Gmail Pub/Sub + Graph subscriptions lands\nin M4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.722Z","updatedAt":"2026-06-15T18:29:12.722Z"},{"id":"021ac6aa-7341-42fa-bd47-0dbb23dfa2ed","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-read-actions","type":"added","scope":"mailbox","summary":"mailbox.thread.list + thread.get + message.get — read-side query surface for the M5 /mail UI.","body":"M5-a from `docs/plans/MAILBOX_BUILD_PLAN.md` §3 M5. Three read-side\nactions that the `/mail` UI (M5-b onwards) consumes for the inbox\nlist, thread reader, and message body fetch.\n\n**`mailbox.thread.list`** — paginated inbox feed:\n\n- Cursor-based pagination on `(last_message_at DESC, id DESC)`.\n  Caller passes the ISO-8601 `cursor` (the last thread's\n  `lastMessageAt` from the previous page); the action returns up to\n  `limit` (default 50, max 100) threads + a `nextCursor` (NULL when\n  exhausted).\n- Optional `unreadOnly: true` filter for the unread-inbox view.\n- Each thread carries the **latest message's snippet** for the\n  list-view preview — fetched in a single round-trip via\n  `DISTINCT ON (thread_id) ORDER BY received_at DESC` against\n  `mailbox_messages`, so a 50-thread page is two queries total (the\n  threads + a batched snippet pull).\n- Ownership: rejects accounts not owned by the actor; rejects\n  `scope='shared'` (those use `mailbox.shared.thread.list`).\n\n**`mailbox.thread.get`** — single thread with full message stream:\n\n- Returns the thread aggregates + all messages ordered\n  chronologically (sent_at ASC) so the conversation UI renders\n  oldest-first.\n- Envelope-only per message — no body bytes. Bodies are fetched\n  on-demand via `mailbox.message.get` to keep the response lean\n  (a 50-message thread shouldn't ship 5 MB just to render the\n  bubble stack).\n- Honours **scope-aware impersonation gate** — when\n  `ctx.impersonatorUserId` is set AND the owning account is\n  `scope='personal'`, denies with `policy_denied`. Business\n  accounts allow impersonation (standard semantics).\n\n**`mailbox.message.get`** — single message + presigned body URLs:\n\n- Full envelope: from / to / cc / bcc / replyTo / subject / snippet /\n  references / headers metadata / SPF/DKIM/DMARC verdicts /\n  attachment metadata.\n- When `withBody: true` (default), mints presigned download URLs\n  for the `body_html_storage_key` and `body_text_storage_key`\n  blobs in object storage. NULL when storage isn't configured or\n  the keys aren't set (e.g. body bytes weren't synced yet — the\n  UI shows \"body not yet synced\").\n- The presign uses the storage client registered via\n  `setMailboxStorageClient` at worker / web boot. Default TTL\n  matches the existing `presignDownload` short window (~10 min).\n- Same impersonation gate as `thread.get`.\n\n**`modules/mailbox/src/runtime.ts`** — process-wide singleton for\nthe storage client (mirrors `@helios/email`'s runtime pattern).\n`setMailboxStorageClient(client)` at worker boot;\n`getMailboxStorageClient()` from actions. Returns `null` when unset\nso actions fall back gracefully.\n\n**Test coverage:** 14 new PGlite-backed tests (122 total in\nmailbox-module):\n\n- `thread.list` orders by `last_message_at DESC` with the right\n  snippet on each row; paginates via cursor across 2 pages;\n  filters to unread when `unreadOnly=true`; rejects another user;\n  rejects missing permission\n- `thread.get` returns thread + chronologically-ordered messages\n  with sender name preserved; blocks impersonator on personal\n  account; rejects another user; returns `not_found` for unknown\n  thread; **allows impersonation on a business account**\n- `message.get` returns full envelope with NULL URLs when storage\n  unset; presigns when storage registered + keys set (verifies\n  the presigned URL shape); skips presign when `withBody=false`;\n  rejects another user\n\nNext: **M5-b** — the polished `/mail` UI itself. Per user direction\n(this session): \"UI is very important. it should be very high\nQuality and Highly polished like the references provided\"\n(Shortwave / Superhuman bar). M5-b designs against that explicit\nbar — see the inline plan in the follow-up commit.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:12.726Z","updatedAt":"2026-06-15T18:29:12.726Z"},{"id":"2376ead9-7721-4aed-8ba9-50295b68e078","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-kanban-polish-and-detail-nav-fix","type":"fixed","scope":"roadmap","summary":"/help/roadmap detail / new-feature pages now actually render; kanban cards repolished.","body":"Clicking a card on the public roadmap kanban — or the \"Request a feature\" button — was updating the URL but not the page; the detail and submit views were hidden behind a missing `<Outlet />` in the parent layout route. The parent now renders the child route when one matches and the kanban otherwise.\n\nWhile there, the kanban itself got a polish pass: columns now scroll horizontally with breathing room (~288px each) instead of cramming into a 6-column grid that made every title wrap into half-words. Cards gained a clearer upvote tile, a two-line title that doesn't fragment, a subtle hover lift, and tighter spacing.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:13.055Z","updatedAt":"2026-06-15T18:29:13.055Z"},{"id":"03967c07-e64c-4e1e-9297-d94617fb2294","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-modal-polish","type":"changed","scope":"chat","summary":"Legacy Modal now portals to body + traps focus + closes on global Esc; the two remaining window.confirm calls in chat moved to the design-system ConfirmDialog.","body":"Polish + a11y pass on every modal-shaped surface in the chat module.\n\n**Legacy `Modal` primitive (`apps/web/src/components/primitives.tsx`).** The\nimperative motion-animated Modal used by every chat dialog had four\nreal-world failure modes:\n\n- **Stacking-context bleed.** The modal rendered inline at its JSX mount\n  point. Mounted inside the sidebar (full of nested stacking contexts\n  from the virtualized message list, composer's overflow:hidden, sticky\n  popovers), the modal's `position:fixed` couldn't always escape its\n  parent's `transform` / `filter` / `will-change`, so it ended up under\n  sibling stacking contexts. **Fixed** by portalling to `document.body`.\n- **Escape only when focused inside.** The keydown handler was on the\n  modal's panel; users who clicked an input then pressed Escape ate the\n  keystroke at the IME but never closed the modal. **Fixed** with a\n  document-level listener that respects `e.defaultPrevented` so a nested\n  popover (mute submenu, picker) can still swallow Escape for its own\n  close.\n- **No focus return.** Closing the modal left focus on `<body>`; keyboard\n  users had to Tab from scratch. **Fixed** — the modal remembers the\n  `activeElement` on mount and restores focus to it on unmount (deferred\n  one microtask so motion's exit animation doesn't fight the blur).\n- **No focus trap.** Tab could escape out of the modal into the chat\n  behind it. **Fixed** with a Tab/Shift+Tab wrap to the first/last\n  focusable inside the panel; deliberately minimal (no shadow-DOM\n  walking, no live-region rescans) — matches Radix Dialog's surface.\n\nPlus initial focus moves into the panel one frame after mount so child\n`autoFocus` (PromptDialog's textarea/input) runs first; the panel itself\ncarries `tabIndex={-1}` for the fallback case.\n\n**Two `window.confirm` migrations.**\n\n- `apps/web/src/components/chat/chat-channels-sidebar.tsx` — deleting a\n  category that has channels showed the native confirm. Now uses\n  `ConfirmDialog` with the destructive-button treatment, the {count}\n  placeholder properly i18n-interpolated, and dark-mode-aware chrome.\n- `apps/web/src/components/chat/card-embed.tsx` — card actions with a\n  `confirm` field (used by AI / integration / event cards to gate\n  destructive payloads) used to surface the native window.confirm. Now\n  uses `ConfirmDialog` and inherits the action's `style==='danger'` so a\n  destructive card action gets the red-button treatment in the modal too.\n\n**`ConfirmDialog` / `PromptDialog`.** The misleading\n`data-helios-chat-popover` attribute on the inner wrapper became\n`data-helios-chat` — same typography opt-in, but no longer claims to be\na popover for outside-click coordination. Visual output unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:38:59.108Z","updatedAt":"2026-06-15T18:38:59.108Z"},{"id":"7d7700cc-9485-4ec2-b6ac-8e45b7cd719f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-trending-on-vote","type":"changed","scope":"roadmap","summary":"Roadmap trending sort refreshes within seconds of a vote — no more 5-minute kanban staleness.","body":"The roadmap kanban's trending sort used to lag actual demand by up to 5 minutes because trending scores were rebuilt only by a periodic cron. Now a `featureVoted` subscriber recomputes the affected feature's score immediately on every vote / unvote / cast-on-behalf — sub-millisecond per call against the votes table. The full-table cron stays in place as an hourly safety net that re-decays features whose votes are aging out without new activity.\n\nAudit findings **B1.a** and **B1.e** from `docs/plans/PLATFORM_ROADMAP_MODULE_AUDIT_AND_IMPROVEMENT_PLAN.md` — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:58.137Z","updatedAt":"2026-06-15T19:59:58.137Z"},{"id":"afce540a-a62b-442d-a1bf-84c600d7f035","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-inbound-builder-ui","type":"added","scope":"forms","summary":"Forms editor can now enable inbound submissions, show the endpoint URL + secret, and map external field names.","body":"The form editor gained a \"Website integration (inbound)\" panel. Enable it to\ngenerate the form's inbound endpoint URL and shared secret (copy / regenerate),\nthen paste them into a WordPress form plugin, your backend, or Zapier. An\noptional field-mapping table wires non-standard external field names (e.g. CF7\n`your-email`, Gravity `input_3`) onto the form's fields — explicit mappings win\nover the built-in auto-detect. All of it rides in the form definition body, so\nthere's no migration. Phase B of the website-integration plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.599Z","updatedAt":"2026-06-15T22:14:00.599Z"},{"id":"a6984d15-2d4c-401a-a66b-75d6593349d5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-command-palette","type":"added","scope":"mailbox","summary":"Cmd+K command palette in /mail — jump to thread, switch account, switch folder, compose, refresh.","body":"M5-b3 slice 3 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3.\nCloses M5-b3 by adding the Superhuman/Shortwave-class\nkeyboard-first jump-to UX.\n\n## What lands\n\nPress `⌘K` (or `Ctrl+K`) anywhere in `/mail` to open the\npalette. Typing filters the result list with a simple\nsubstring-against-haystack match; arrow keys (or Tab /\nShift+Tab) walk the list; Enter activates; Esc closes.\n\nThe result list mixes four entry types:\n\n- **Threads** in the current account + folder. The\n  substring matches against subject + participant names.\n  Each row shows subject + participants + snippet preview.\n- **Folders**: every folder OTHER than the one currently\n  active, labelled \"Go to {Inbox / Starred / Archive /\n  Trash / All mail}\".\n- **Accounts**: every connected account OTHER than the\n  active one, labelled \"Switch to {emailAddress}\".\n- **Actions**: \"Compose new message\" + \"Refresh\".\n\nWhen the query is empty the palette shows the actions\nfirst, then the folder hops, then the accounts, then the\ncurrent visible threads — so the palette is a useful\nlaunchpad even before the user types.\n\nThe palette mounts inside `/mail` (NOT global). It listens\nfor `⌘K` only when the route is mounted; outside `/mail`\nthe shortcut is yours to bind elsewhere. The keystroke\nfires even while typing in inputs (compose dock, search\nfields) so the palette is always reachable.\n\nThe reader's no-thread-selected hint footer was updated\nwith `⌘K palette` so users discover the shortcut without\ndocs.\n\n## Implementation notes\n\n- Lives entirely inside `apps/web/src/routes/mail.tsx` —\n  no new files, no new actions.\n- Uses the existing `mailbox.thread.list` query cache for\n  the threads list. The palette is a view over the same\n  data the middle pane shows; no extra round-trip.\n- Result cap at 200 entries to keep the render snappy on\n  very-busy mailboxes. Substring is fast enough that a\n  proper fuzzy-search index isn't needed yet.\n- Mouse hover updates `activeIdx` so keyboard + mouse\n  navigation stay in sync.\n\n## Net behavior\n\nCombined with the prior slices, `/mail` is now usable\nkeyboard-only end-to-end:\n- `⌘K` open palette → type → Enter to jump\n- `J / K` walk threads in the current folder\n- `E / S / U` archive / star / read-toggle\n- `C / R` compose / reply\n- `⌘⏎` send from the compose dock\n\nThe mailbox is fully Superhuman-class on the operator-\nfacing keyboard axis.\n\nCloses M5-b3 of `MAILBOX_BUILD_PLAN`. Next on the M-queue:\nM2-f shared inbox + admin business provisioning, M1g/h\nIMAP/JMAP, M10+ AI panel.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:12:13.491Z","updatedAt":"2026-06-15T20:12:13.491Z"},{"id":"406f188d-82f8-406f-b26e-52efc171026c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-shared-ui","type":"added","scope":"mailbox","summary":"Shared inboxes surface in /mail sidebar + /settings/mailbox roster admin — team members reach shared mailboxes via the existing polished UI.","body":"M2-f slice 3 from `docs/plans/MAILBOX_BUILD_PLAN.md` §3.\nThe previous two slices built the shared-inbox actions; this\nslice wires them into the existing `/mail` chrome and adds the\nadmin roster UI in `/settings/mailbox`. Shared inboxes are now\nend-to-end usable.\n\n## `/mail` integration\n\nThe polished three-pane client now lists shared inboxes alongside\nthe actor's personal + business mailboxes.\n\n### Account fetch + merge\n\n- New query: `mailbox.shared.account.list` runs alongside the\n  existing `mailbox.account.list_own`. When the actor lacks\n  `mailbox:account:read:shared` the action denies + the catch\n  swallows + the sidebar simply doesn't show shared inboxes.\n- Both result sets merge into a unified `Account[]` in the\n  sidebar. Shared inboxes carry `memberRole` + `memberCount` so\n  the UI can disable write affordances for observers.\n\n### Action-set router\n\nA single `actionNamesFor(scope)` helper returns the right action\nnames based on the active account's scope. Every callsite —\nthread list, thread get, message get, mark_read, star, archive,\nhover prefetch, refresh, compose send — pulls from `actionSet`\ninstead of hardcoding `mailbox.thread.*`. The UI code is now\nscope-agnostic: switching to a shared inbox transparently flips\nevery call to `mailbox.shared.*`.\n\n### Sidebar polish\n\n- Shared inboxes get a purple `team` badge + a member-count\n  subtitle (`3 members · agent`). Personal `biz`/none stay as is.\n- Tooltip on the badge: \"Shared inbox · N members · you are\n  observer/agent/admin\".\n\n### Observer guardrails\n\nObservers (read-only members) get:\n- A `Read only` chip next to the reader-pane toolbar.\n- `Reply`, `Star`, `Mark unread`, `Archive` buttons all disabled\n  with a tooltip explaining why.\n- Server-side enforcement is still the source of truth (the\n  shared mutations + send reject observers with `policy_denied`),\n  but the disabled buttons prevent confusion.\n\n### AI panel\n\nThe `Summarise with AI` + `Draft reply with AI` panel is hidden\non shared inboxes for now — the\n`mailbox.shared.thread.{summarize, draft_reply}` actions land in\na follow-up slice so the shared-inbox flow stays explicit about\nwhich actions are available.\n\n### Message-body presign\n\n`SharedMessageGetOutput` gains `bodyHtmlUrl` + `bodyTextUrl`\nfields (mirroring `MessageGetOutput`). The shared `message.get`\nhandler now mints presigned download URLs from the storage\nclient when `withBody` isn't false. Without this the\nMessageBubble's iframe would always show \"Body not yet synced\"\non shared messages.\n\n## `/settings/mailbox` integration\n\nThe settings page already manages the actor's personal + business\nmailboxes. This slice appends a `Shared inboxes` card showing\nevery shared inbox the actor is a member of (or every shared\ninbox in the org when the actor holds\n`mailbox:account:shared:catalog`).\n\n### Roster row\n\nEach shared inbox renders as a clickable row showing:\n- The provider glyph + email address\n- `team` badge + `you: <role>` badge\n- Member count + last-sync time\n\n### Expanded roster (admins)\n\nClicking expands into the member roster (calls\n`mailbox.shared.member.list`):\n- One row per active member: name, email, role badge.\n- Admins additionally see a `Remove` button and a footer form\n  with an email input + role select + Add button.\n- Add resolves email → userId via `iam.user.list` (search field;\n  matches exact email; rejects with \"No user found\" toast\n  otherwise), then calls `mailbox.shared.member.add`.\n- Remove calls `mailbox.shared.member.remove` (soft delete).\n- Both mutations invalidate the member list AND the shared-account\n  list so the parent rows refresh member counts.\n\nNon-admin members can read the roster but the Add form + Remove\nbuttons are hidden.\n\n### Empty state\n\nThe whole `Shared inboxes` card is suppressed when the actor has\nno shared-inbox memberships AND lacks the catalog permission —\nso users without the feature don't see an empty card cluttering\nthe page.\n\n## Net behavior\n\nA team member is added to a shared inbox by an admin in\n`/settings/mailbox`. They open `/mail`, see the inbox under\ntheir personal accounts with a `team` badge, click → land on\nthe inbox's threads. Folder filter, `J/K` walk, `E/S/U` shortcuts,\nCmd+K palette, attachment downloads, reply → all work\nidentically. Sent replies come from `support@acme.com` (or\nwhatever the shared inbox address is), not the agent's personal\nemail.\n\nObservers see the same UI but with mutation buttons disabled\n+ a `Read only` chip explaining why. Server-side rejection\n(`policy_denied`) is the actual gate.\n\n## Net mailbox campaign status\n\nM2-f closed end-to-end:\n- slice 1 — roster actions\n- slice 2 — read/mutate/send actions\n- slice 3 — UI integration in /mail + /settings/mailbox\n\nNext on the queue: M1g/h — IMAP + JMAP provider adapters (so\nthe mailbox supports providers beyond Gmail + Microsoft Graph),\nthen M10b — semantic search across all mail (needs embeddings\nbackfill), then misc polish.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.778Z","updatedAt":"2026-06-15T22:14:00.778Z"},{"id":"8c780667-a9e6-40ee-a36d-dad38b8241eb","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-feed-org-banner-and-setup-route","type":"changed","scope":"web","summary":"OrgSetupBanner + /setup wizard route read + write through the unified feed (Phase 5.D/2-3).","body":"Two more UI consumers off the legacy actions.\n\n**OrgSetupBanner** now reads `platform.onboarding.feed.list`\nand filters for `kind: 'org'` entries with `enforced: true`. The\nsaas resolver server-side already encapsulates the legacy\n\"isOrgOwner\" check by setting `enforced: true` only when the\nactor is the org owner — so the banner just filters and renders.\n\n**`/setup` wizard route** migrates both its read (`useQuery` on\nthe feed) and writes (`platform.onboarding.feed.complete_step` /\n`skip_step`). The legacy shape (`steps`, `state`, `meta`,\n`isComplete`, `nextStepId`, `isOrgOwner`) is derived inline from\nthe feed's items + progress — same display semantics, same\n\"you're done\" navigation behaviour. Step-id comparisons use the\nnamespaced ids (`org:brand`, `org:modules`, etc.).\n\nAfter this commit no live `callAction(...)` of\n`iam.user.onboarding.get` or `saas.organization.setup.get`\nremains. The legacy GET actions are now safe to delete in Phase\n5.D/4; the legacy query-key invalidations in security.tsx /\nprofile.tsx / verify-email.tsx / onboarding.tsx /\nauth-broadcast.ts get dropped in Phase 5.D/5.\n\nWeb typecheck clean on the two migrated files.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.966Z","updatedAt":"2026-06-15T22:14:00.966Z"},{"id":"4a63a01e-3c04-4b2e-b1ed-be8185de04e8","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-feed-root-gate-consolidation","type":"changed","scope":"web","summary":"__root.tsx consolidates fetchUserOnboardingStatus + fetchOrgSetupStatus into one platform.onboarding.feed.list call (Phase 5.D/1).","body":"First Phase 5.D cleanup. The root-route gate chain previously\nfired three legacy actions per authed navigation:\n\n1. `hrm.employee.my_onboarding_status` (HRM joining-pack gate)\n2. `iam.user.onboarding.get` (user wizard)\n3. `saas.organization.setup.get` (org owner wizard)\n\n(2) and (3) collapse into a single\n`platform.onboarding.feed.list` read. The feed's `blocking`\nfield encapsulates every legacy gate flag — `mustVerifyEmail`,\n`requireUserOnboarding`, `requireOrgSetup`, and `isOrgOwner` —\nbecause the iam + saas resolvers built those checks into the\nper-entry `blocking` field. The `itemId` prefix determines the\nredirect target: `user:*` → `/onboarding`, `org:*` → `/setup`.\n\nHRM stays separate because the joining-pack flow is intentionally\nout of scope for the feed (spec §6 — candidate-touchpoint UX).\n\nNet: one fewer round-trip on every authed navigation, one less\nReact-Query cache slot, identical gate semantics.\n\nThe legacy actions (`iam.user.onboarding.get`,\n`saas.organization.setup.get`) stay registered through the rest\nof Phase 5.D since other surfaces (org-setup banner, `/setup`\nwizard) still call them. They get deleted in a later sub-phase\nonce those migrate.\n\nPre-existing TanStack-router TypeScript noise on line 360\n(`redirect({to: safeDest})`) is unrelated and predates Phase 5.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:00.988Z","updatedAt":"2026-06-15T22:14:00.988Z"},{"id":"ce9774b6-97ac-4c4e-8c2c-ddc1f2df4e2a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"passkeys-card-extracted","type":"changed","scope":"web","summary":"PasskeysCard extracted from settings/security.tsx (1765 → 1630 lines).","body":"Pure code-organisation follow-up. WebAuthn enrol +\nper-passkey revoke now lives at\n`apps/web/src/components/passkeys-card.tsx` (158 lines).\n\nSame authClient.passkey.* calls, same toast strings, same\ncross-tab broadcast (`'passkeys'`) + same-tab invalidations\nacross passkeys list / count / onboarding feed / me.\n\nNo behaviour change. security.tsx steps from 1765 → 1630\nlines.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.051Z","updatedAt":"2026-06-15T22:14:01.051Z"},{"id":"0763f22d-b038-403f-886d-a049c51da60c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-builder-layout-switcher","type":"added","scope":"forms","summary":"The form builder can switch a form between single-page and multi-step wizard layouts.","body":"The form editor's appearance settings gained a \"Form layout\" control — switch a\nform between a single stacked page and a multi-step wizard (one section at a\ntime, with a progress bar) without touching JSON. The live preview updates\nimmediately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.133Z","updatedAt":"2026-06-15T22:33:59.133Z"},{"id":"27e357dc-3a0c-4c26-b512-174b0e6ec24c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-on-every-plan","type":"changed","scope":"mailbox","summary":"Mailbox is now a locked module on the app rail — visible to every workspace, included on every plan, with a backfill for existing orgs.","body":"Product directive: Mailbox should ride on the same rail as\nDashboard / Settings / Activity — a core platform surface, not\na module a workspace has to opt into. Existing\nsubscribers/organizations get it without any admin action.\n\n## Three pieces\n\n### 1. Signup module catalog — mark Mailbox locked\n\n`apps/web/src/lib/signup-state.ts` — `MODULE_CATALOG` gains a\n`mail` entry with `locked: true`. The signup wizard's module\npicker no longer surfaces it as opt-in; new workspaces auto-\ninclude it in `enabledModules`.\n\n### 2. Workspace-prefs predicate — locked modules are always allowed\n\n`apps/web/src/lib/workspace-prefs.ts` — the `isAllowed`\npredicate now short-circuits to `true` for any id in\n`LOCKED_MODULE_IDS`. This is the **automatic backfill**: every\nexisting org whose stored `enabledModules` list pre-dates the\nMailbox catalog entry would otherwise hide the tile; this\noverride flips it on without a DB query or admin action.\n\nFuture locked modules (anything added with `locked: true` in\n`MODULE_CATALOG`) get the same treatment for free — no per-\nrelease backfill migration needed.\n\n### 3. Belt-and-suspenders SQL backfill on saas_plans\n\n`packages/db/drizzle/0271_0272_saas_plans_mailbox_enabled_backfill.sql`\nsets `features.mailbox.enabled = true` on every non-deleted\n`saas_plans` row.\n\nThe plan-feature catalog's `MAILBOX_ENABLED` already declares\n`defaultsByPlan: {free, starter, business, enterprise: true}`,\nso the merged read returns true regardless — but normalising\nthe jsonb makes:\n- the SaaS plans admin UI's \"Features\" tab show Mailbox as ON\n  without relying on the catalog default,\n- AI-readable plan inspections honest (the data, not a\n  computed default),\n- any code that reads `features.mailbox.enabled` directly\n  (skipping the catalog) gets the right value.\n\nIdempotent: re-running sets the same path to true.\n\n## Net behavior\n\n- New signup → Mailbox tile in the app rail by default. Wizard\n  doesn't ask.\n- Existing org with a pinned `enabledModules` list → Mailbox\n  tile appears on next render. No admin action.\n- Existing plan with `features.mailbox.enabled` unset → still\n  unlocked (catalog default) AND now explicitly true in the\n  jsonb after the migration runs.\n\nCombined with the prior commit's registry-boot fix, the\nmailbox is now reachable from the rail AND its actions\nactually resolve in production.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.392Z","updatedAt":"2026-06-15T22:33:59.392Z"},{"id":"73db7990-f764-4ea4-955d-3eb18458fe0a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-feed-wizard-migration","type":"changed","scope":"web","summary":"/onboarding wizard route reads + writes through the unified onboarding feed (Phase 5.B/3).","body":"Third UI consumer migrated. The wizard now reads\n`platform.onboarding.feed.list` and mutates via\n`platform.onboarding.feed.{complete_step, skip_step}` (which\ndispatch internally to the legacy iam actions through the\nPhase 5.C dispatcher).\n\nMigration details:\n\n- The legacy shape (`steps`, `state`, `meta`, `progress`,\n  `isComplete`, `nextStepId`, `enforced`, `emailVerified`,\n  `mustVerifyEmail`) is derived inline from the feed's\n  `items[]` + `progress`. The wizard's \"all done\" celebration\n  card now uses `progress.pending === 0` instead of the\n  removed `isComplete`. The \"Next\" badge uses the first\n  uncompleted required user item.\n- All step-id comparisons (`verify_email` inline send,\n  auto-derive provenance labels) now compare against the\n  namespaced ids (`user:verify_email`,\n  `user:enable_two_factor`, etc.).\n- Both mutations invalidate BOTH the new feed query key AND\n  the legacy `['iam.user.onboarding.get','self']` key so any\n  in-flight legacy reader (e.g. an unmigrated test snapshot\n  during Phase 5.D's removal) stays in sync. The legacy key\n  drops in Phase 5.D.\n- `complete.onSuccess` no longer receives `isComplete` from\n  the dispatcher's per-item response — instead the handler\n  re-reads the cache after invalidation to decide whether to\n  navigate to `/`. Same UX, different signal.\n\nWeb typecheck clean on the wizard file. iam tests still green\n(legacy actions untouched).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.012Z","updatedAt":"2026-06-15T22:14:01.012Z"},{"id":"e61c80d6-5b2d-44db-a5e5-d2d34f9ba281","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"web-build-fix","type":"fixed","scope":"web","summary":"Production Docker build green — TanStack route literal + recent-accounts-corner import path.","body":"The production Vite/Docker build had been failing in two places:\n\n1. **`apps/web/src/routes/account.messages.tsx`** — the new\n   `/account/messages` route (Phase 2 of the chat-in-client-portal\n   plan, commit `6a6db29f`) used `createFileRoute('/account/messages'\n   as never)` because the route tree generator hadn't yet been told\n   about the path. TanStack's `router-generator` plugin parses the\n   source file at config-resolution time and requires a plain string\n   literal — the `as never` cast made it throw \"expected route id to\n   be a string literal\" before the build even started. The cast is\n   gone; the path is now `'/account/messages'` as a literal, with a\n   one-line `// @ts-expect-error` directive bridging the gap until\n   the Vite plugin regenerates `routeTree.gen.ts` (the directive\n   resolves itself once the regenerated tree teaches `tsc` about the\n   path).\n\n2. **`apps/web/src/components/recent-accounts-corner.tsx`** — the\n   recent-accounts top-right pill (auth landing page, commit\n   `8d1e080a`) imported `clearAllAccounts`, `forgetAccount`,\n   `getRecentAccounts`, and `RememberedAccount` from\n   `'./recent-accounts'`, but those symbols live in\n   `'../lib/auth-memory'` (the underlying store). `recent-accounts.tsx`\n   imports them from `auth-memory` for its own use and does not\n   re-export them. The corner pill now imports them directly from\n   `../lib/auth-memory`; the `AccountRow` component + `methodMeta`\n   helper still come from `./recent-accounts` where they're actually\n   defined.\n\nFull client + service worker build now succeeds (29.9s + 0.1s);\n6922 modules transformed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.452Z","updatedAt":"2026-06-15T22:14:01.452Z"},{"id":"b3dd4d8c-5b1b-47da-b585-3e10067146f7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-polish-round-2","type":"changed","scope":"roadmap","summary":"Roadmap polish — column-shaped skeletons, title-cased visibility labels, accessible duplicate modal, scrollable admin kanban.","body":"A bundle of small visual + accessibility touch-ups on the roadmap module:\n\n- **Public kanban skeleton** mirrors the real column layout — header strip, count chip, and two card-shaped tiles — instead of six blank monoliths. First paint after a slow load no longer asks the eye to readjust.\n- **Visibility dropdowns** in both the customer submit form and the admin edit sheet now show human-facing labels (\"Public\", \"Signed-in users\", \"Customers only\", \"Private (admin)\") instead of the raw enum values.\n- **Acknowledge-duplicate modal** on the submit form gains `aria-labelledby` pointing at the visible title so screen readers announce \"You may have a duplicate\" instead of the bare role.\n- **Admin /saas/roadmap Kanban tab** switches from a 6-column grid (cramped on 1440p, wasted on 4K) to the horizontal-scroll layout the public board already uses — each column gets 288 px and titles render in full.\n\nAudit findings **E4, E8, E9, E10** — closed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.167Z","updatedAt":"2026-06-15T22:14:01.167Z"},{"id":"f9b0da4a-3ec1-40e7-90ca-09706a8177d3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"two-factor-card-extracted","type":"changed","scope":"web","summary":"TwoFactorCard + MethodCard + downloadBackupCodes extracted from settings/security.tsx (1279 → 533 lines).","body":"The biggest extract of the day. The TOTP two-factor card —\nenrolment with QR + manual secret, method picker, verify-and-\nenable, enabled-manage panel (regenerate backup codes + disable),\n3-state machine — moves to\n`apps/web/src/components/two-factor-card.tsx` (765 lines).\n\nSame `authClient.twoFactor.*` calls, same optimistic `me` cache\npatch on verify success, same orphan-secret cleanup on cancel\n(Phase 1 audit fix from `040068a6`), same broadcast (`'two-\nfactor'`) + the four same-tab invalidations (passkeys count +\nlist + onboarding feed + me).\n\n`MethodCard` (the tile in the picker) and `downloadBackupCodes`\n(the codes-to-.txt helper) move alongside since they're only\nconsumers of the TwoFactorCard.\n\nsecurity.tsx: 1279 → 533 lines. Net for today's card extracts\n(AccountDeletion + Passkeys + LoginHistory + ApiKeys + TwoFactor)\nplus the earlier TotpChallenge:\n\n  - login.tsx     1394 → 1057 (−337)\n  - security.tsx  1937 →  533 (−1404)\n\nsettings/security.tsx is now a thin shell that composes:\nSecurityScoreCard, TwoFactorCard, PasskeysCard, TrustedDevices,\nLinkedAccounts, ActiveSessions (still inline), LoginHistory,\nApiKeys, DataExport (still inline), AccountDeletion.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:14:01.435Z","updatedAt":"2026-06-15T22:14:01.435Z"},{"id":"5c68d55e-1f38-4b62-915c-acdf2633faa1","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-intent-step-extracted","type":"changed","scope":"web","summary":"IntentStep + IntentCard extracted from signup.tsx (3142 → 2946 lines).","body":"First slice of the signup.tsx step-by-step extraction. The \"join an\nexisting workspace or create a new one\" intent picker — staggered\nspring entrance, hover lift + accent halo + arrow slide, gentle\npress-in on tap — moves to\n`apps/web/src/components/signup/intent-step.tsx` (185 lines) along with\nthe private `IntentCard` consumer and the `INTENT_CARD_VARIANTS`\nanimation constants.\n\nPure code-organisation: same hooks (`useNavigate`, `useAppConfig`,\n`useTranslation`), same translation keys (`intent_eyebrow`,\n`intent_welcome_prefix`, `intent_subtitle`, `intent_invite_*`,\n`intent_create_*`, `intent_recommended`, `intent_tenant_note`), same\nnavigation targets (`{ step: 'join' }` / `{ step: 'account' }`).\n\nsignup.tsx: 3142 → 2946 lines. Sets up the rest of the wizard\n(JoinStep, AccountStep, WorkspaceStep, PurchasePlanStep, ModulesStep,\nInviteTeamStep, CompleteStep) for follow-up commits.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:33:59.448Z","updatedAt":"2026-06-15T22:33:59.448Z"},{"id":"da058277-a3f5-429d-ac68-15a97cad2d8f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-detail-page-react-300-hotfix","type":"fixed","scope":"roadmap","summary":"Fix React error #300 crash when navigating from /help/roadmap to a feature detail page.","body":"Clicking any card on the public roadmap kanban (or visiting `/help/roadmap/<slug>` directly with a `?tab=` query) crashed the page with React error #300 — \"rendered fewer hooks than expected\". The parent route's component was returning `<Outlet/>` early when a child route was active, which skipped a `useQuery` call beneath. React saw a different hook count on the second render and bailed.\n\nThe early return now happens AFTER every hook has fired; the parent's summary fetch additionally short-circuits via `enabled: !hasChildRoute` so it doesn't waste a network call while the child route renders.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T23:11:32.110Z","updatedAt":"2026-06-15T23:11:32.110Z"},{"id":"cb36cb50-d17c-4c01-b2f2-9b0865cd5c5c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-complete-step-extracted","type":"changed","scope":"web","summary":"CompleteStep + DirectLandingRecovery extracted from signup.tsx (2623 → 2397 lines).","body":"Third slice of the signup.tsx step-by-step extraction. The final\n\"workspace ready\" scene — readiness polling with exponential backoff,\npaid-signup cookie sync via `authClient.organization.setActive`, the\nmount-time-anchored 25 s safety timeout, the 4 s direct-landing\nrecovery — moves to `apps/web/src/components/signup/complete-step.tsx`\n(219 lines) along with its private `DirectLandingRecovery` consumer.\n\nThe component now takes the typed `SignupSearch` shape as a prop\n(`orgId`, `redirectTo`, `fromSession`) so it doesn't need to reach\nback into the route module's `Route.useSearch()`. StepRouter\nhands those down.\n\nSame `saas.organization.signup_readiness` polling cadence\n(600 → 1500 → 3000 ms), same orgId-hint behaviour, same celebration\ndelay (1600 ms), same safety net (25 000 ms), same direct-landing\ntrigger (4 000 ms).\n\nsignup.tsx: 2623 → 2397 lines. Remaining wizard slices: AccountStep,\nWorkspaceStep, PurchasePlanStep, ModulesStep, InviteTeamStep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T23:11:32.129Z","updatedAt":"2026-06-15T23:11:32.129Z"},{"id":"351446be-cb7e-4cde-80df-8c954cff94a1","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-invite-team-step-extracted","type":"changed","scope":"web","summary":"InviteTeamStep + InviteRow + BackButton extracted from signup.tsx (2397 → 2050 lines).","body":"Fourth slice of the signup.tsx step-by-step extraction. The optional\n\"send teammates an email\" step — per-row email validation, animated\nadd/remove rows via `AnimatePresence`, per-invite failure visibility\n(no more silent Promise.allSettled swallowing), valid-count counter —\nmoves to `apps/web/src/components/signup/invite-team-step.tsx` (318\nlines) along with its private `InviteRow` consumer.\n\nThe `BackButton` pill that the workspace / plan / modules / invite\nsteps share moves to `components/signup/back-button.tsx` (24 lines),\nmirroring the earlier `back-to-intent.tsx` extract. Same\n`useNavigate` + i18n behaviour.\n\nSame `authClient.organization.inviteMember` call, same failure\ntoast + inline error format, same skip-to-complete navigation.\n\nsignup.tsx: 2397 → 2050 lines. Remaining wizard slices: AccountStep,\nWorkspaceStep, PurchasePlanStep, ModulesStep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T23:11:32.399Z","updatedAt":"2026-06-15T23:11:32.399Z"},{"id":"66fe2c05-1287-4381-abd1-9109f96238cc","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-join-step-extracted","type":"changed","scope":"web","summary":"JoinStep + invitation-shared helpers + BackToIntent extracted from signup.tsx (2946 → 2623 lines).","body":"Second slice of the signup.tsx step-by-step extraction. The \"paste an\ninvite code → preview → accept\" branch moves to\n`apps/web/src/components/signup/join-step.tsx` (255 lines) along with\nits private `InvitationPreviewPanel` and the `InviteCodeSchema`.\n\nThe shared invitation helpers — `InvitationPreview` type,\n`extractInviteToken`, `lookupInvitation` — move to\n`components/signup/invitation-shared.ts` (84 lines) so AccountStep can\nkeep its existing call into `iam.invitation.preview` without\nre-implementing the helpers. AccountStep is still in `signup.tsx`;\nit now imports them from the shared file.\n\nThe \"Back\" pill that the join + account steps share moves to\n`components/signup/back-to-intent.tsx` (22 lines).\n\nPure code-organisation: same translation keys, same action calls\n(`iam.invitation.preview`, `iam.invitation.complete`,\n`authClient.organization.acceptInvitation`), same animation, same\nauto-lookup on deep-link.\n\nsignup.tsx: 2946 → 2623 lines. Remaining wizard slices: AccountStep,\nWorkspaceStep, PurchasePlanStep, ModulesStep, InviteTeamStep,\nCompleteStep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T23:11:32.404Z","updatedAt":"2026-06-15T23:11:32.404Z"},{"id":"6332d3b8-55a6-4ab7-a972-eaf0b4d8de03","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"marketing-roadmap-card-polish","type":"changed","scope":"website","summary":"Marketing /roadmap cards pick up the same status-edge accent stripe + hover lift as the in-app roadmap.","body":"The public marketing roadmap (`/roadmap`) used a flat card with a coloured dot in the column header for status signal. Each card now wears the same left-edge status accent stripe (Linear-style) used on the in-app `/help/roadmap` views, gets a subtle hover lift, and the title weight + colour shifts on hover so the click affordance is obvious. Mirrors the visual language operators see in `/saas/roadmap` and customers see in `/help/roadmap`, so visitors moving between the marketing site, the help center, and the app see one consistent roadmap surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.438Z","updatedAt":"2026-06-16T04:14:07.438Z"},{"id":"2f92f758-44b9-4f59-b5a5-f8ffd2206880","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-accent-token-fix","type":"fixed","scope":"roadmap","summary":"Roadmap buttons no longer turn invisible after upvoting — fix references a non-existent CSS variable.","body":"The upvote button on `/help/roadmap/<slug>` disappeared after a successful click, leaving only an unclickable hole. The \"Request a feature\" CTA had the same problem. Both rendered with `bg-[var(--accent-primary)]` and `text-[var(--fg-on-accent)]` — but `--accent-primary` is not a defined token in the design system (the actual name is `--accent`). The bg fell through to transparent; the text and icon stayed white → invisible on top of the page background.\n\nBulk-renamed every `--accent-primary` reference to `--accent` in `apps/web/src/routes/help/roadmap.tsx`, `roadmap.$slug.tsx`, `roadmap.new.tsx`, and `roadmap.preferences.tsx`. Buttons, hover borders, and accent strips all render the brand color again.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:38.263Z","updatedAt":"2026-06-16T00:03:38.263Z"},{"id":"3027b201-4a55-4778-a18b-c0bcf90d0a86","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-signature-ui","type":"added","scope":"mailbox","summary":"Compose dock auto-appends the active account's signature on new messages, and /settings/mailbox gains a per-account signature editor.","body":"UI follow-up to the `mailbox-signature-and-staging-fix` backend\ncommit (`83f7f1cd`).\n\n## Compose dock auto-append\n\nWhen the user opens a brand-new compose (no restored local\ndraft, no incoming AI/reply body), the dock now calls\n`mailbox.account.get_signature` for the active account and\nappends the `signature_text` to the body with a blank line\nseparator.\n\n- Replies: never auto-append. The user usually inherits their\n  signature from the provider's quoted-message context.\n- AI-seeded drafts (`Draft reply with AI`): never auto-append.\n  The AI's draft already includes a sign-off.\n- Restored local drafts: never auto-append. The saved text is\n  trusted as-is.\n- The append only fires when the body is still empty at the\n  moment the query resolves; if the user has started typing\n  in the meantime, we leave their text alone.\n- Switching accounts in the dock re-triggers the fetch + append\n  (so swapping from personal → business pulls the business\n  signature in for that send).\n\n## Settings → Mailbox signature editor\n\nEvery account row in `/settings/mailbox` gains an `Edit\nsignature` toggle. Expanding shows a 4-row plain-text\n`<textarea>` plus Save + Clear buttons.\n\n- Personal + business: the account owner edits their own.\n- Shared: the row also has an editor, but only admin members\n  can save (the action returns `policy_denied` otherwise).\n  Observers + agents see the textarea as a read-only preview\n  of what they'll send with.\n\nMutation invalidates the per-account signature query so the\nnext compose-dock open picks up the new text.\n\n## Net\n\nA user sets their signature once in `/settings/mailbox`, then\nevery new message they compose gets it automatically.\nReplies still feel native (no double signature). Shared\ninboxes get one team signature controlled by admins.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:38.276Z","updatedAt":"2026-06-16T00:03:38.276Z"},{"id":"5877be96-a4b3-447d-92e3-5b2ca2748a9c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-account-step-extracted","type":"changed","scope":"web","summary":"AccountStep extracted from signup.tsx (1687 → 1315 lines).","body":"Sixth slice of the signup.tsx step-by-step extraction. Step 3 — name\n+ email + password (with live strength meter), Google One Tap, social\nproviders, SAML SSO link, CAPTCHA, magic-link sign-up — moves to\n`apps/web/src/components/signup/account-step.tsx` (359 lines).\n\nThe invite-aware branch (URL `?invite=` token locks the email field,\nshows an inline org preview panel, and accepts the invitation\npost-signup via `authClient.organization.acceptInvitation` +\n`iam.invitation.complete` so the user lands directly in the org)\nmoves intact.\n\nSame `AccountStepSchema` validation, same captcha header threading,\nsame `applyAuthChange` post-signup navigation, same magic-link\nsuppression when an invite is present. The component takes the typed\n`SignupSearch` shape as a prop so it doesn't reach back into\n`Route.useSearch()`.\n\nsignup.tsx: 1687 → 1315 lines. Remaining wizard slices:\nPurchasePlanStep, ModulesStep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:38.555Z","updatedAt":"2026-06-16T00:03:38.555Z"},{"id":"457b622a-2e44-47ad-bb45-2a64084b8aaa","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-views-and-card-polish","type":"changed","scope":"roadmap","summary":"Roadmap gets a Timeline view, polished cards with thumbnails + avatars, and a stateful upvote affordance.","body":"The public roadmap surface levels up in three coordinated ways:\n\n- **Three views.** The two-tab \"Roadmap / Browse all\" UI becomes List, Kanban, and Timeline. List is the searchable, filterable grid; Kanban is the existing six-column horizontal-scroll board; Timeline groups by `targetLabel` (\"2026 Q3\", \"Soon\", \"Backlog\") with Shipped pinned first and Unscheduled last. The legacy `?tab=board` deep links alias to List so nothing breaks.\n- **Card polish.** Every card across all three views now renders the same polished `FeatureCard`: the first image attachment becomes a thumbnail at the top, a stateful upvote tile shows filled amber when the viewer has voted (outline otherwise), and a footer strip surfaces the requester's avatar + name plus the operator who last touched the row. Comment counts and \"other attachments\" pick up a paperclip chip.\n- **New action `platform.roadmap.vote.my_voted_ids`.** Returns the subset of supplied feature IDs the actor has voted on — one round-trip per board render — so the stateful upvote tile renders without N+1 lookups.\n\nBackend: `FeaturePublicDto` gains `requestedBy` + `lastTouchedBy` author summaries (id + name + image), populated via a single bulk users join. `list_public`, `get_public`, and `board.summary_public` all pass through the same `loadAuthorLookup()` helper so cards have everything they need in the initial render.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:38.574Z","updatedAt":"2026-06-16T00:03:38.574Z"},{"id":"4fdc801f-7384-4d5d-bb81-b7ab8e5a24ed","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-snooze-and-state-secret-fallback","type":"added","scope":"mailbox","summary":"Snooze threads with a reader-pane picker + Snoozed sidebar folder; OAuth state secret now derives from BETTER_AUTH_SECRET when not set explicitly.","body":"Two-part:\n\n## Critical: OAuth state secret fallback\n\nStaging was failing boot with\n`MAILBOX_OAUTH_STATE_SECRET must be set (>=16 chars)`. The\ndedicated env var is documented but every deployment now has\nto set it before mailbox OAuth works — a deploy block, not a\nreal security upgrade.\n\nFix in `modules/mailbox/src/lib/state-token.ts`'s\n`resolveSecret()`: the lookup chain is now\n\n1. Explicit `secret` argument (tests, operators with a\n   dedicated rotation surface).\n2. `MAILBOX_OAUTH_STATE_SECRET` env var.\n3. **`BETTER_AUTH_SECRET` env var, derived through\n   `sha256(BETTER_AUTH_SECRET + ':mailbox-oauth-state-secret-v1')`**.\n   `BETTER_AUTH_SECRET` is already required to be ≥32 chars in\n   every deployment per `packages/config/env.ts`, so the\n   derivation never fails when auth itself is configured.\n\nDomain-separation keeps the mailbox state-token key strictly\ndifferent from any other BETTER_AUTH_SECRET-derived value.\nOperators who want a dedicated rotation cadence can still set\nthe explicit env var.\n\n`state-token.test.ts` updated:\n- The \"throws when secret is missing\" test now deletes BOTH\n  env vars before asserting.\n- New test: when only `BETTER_AUTH_SECRET` is set, signing\n  works and returns a valid token.\n\n## Thread snooze\n\nSchema column `mailbox_threads.snooze_until` shipped in M0;\nthis commit adds the actions, the cron, and the UI.\n\n### Actions\n- **`mailbox.thread.snooze({threadId, until})`** — Inbox\n  hides the thread; appears under the new `snoozed` folder\n  with its wake-up time. Rejects past timestamps + anything\n  more than 365 days out.\n- **`mailbox.thread.unsnooze({threadId})`** — clears\n  immediately, restores to Inbox. Idempotent.\n- Both gated by `mailbox:thread:write:own` and routed\n  through the existing `loadOwnedThread` ownership guard\n  (Q15-fenced for personal scope).\n\n### Folder filter\n- `thread.list` and `shared.thread.list` both gain a\n  `snoozed` folder option.\n- Inbox queries now exclude threads with a future\n  `snooze_until` (`OR snooze_until <= now()` predicate). Even\n  if the wake cron is delayed, readers never see a snoozed\n  thread back in Inbox before time.\n\n### Worker cron\n- New `runMailboxSnoozeWake({db})` clears every\n  `snooze_until <= now()`.\n- `apps/worker/src/mailbox-snooze-wake-cron.ts` ticks every\n  60s with re-entrancy guard. Multi-replica safe (idempotent\n  UPDATE).\n\n### UI in `/mail`\n- `Snoozed` entry in the sidebar `FOLDERS` list (Clock icon).\n- New `SnoozeButton` in the reader header — clicking shows a\n  popover with sensible presets: `In 1 hour`, `In 3 hours`,\n  `This evening` (only before 5pm local), `Tomorrow morning`\n  (9am), `Next week` (9am).\n- When the active thread is already snoozed, the button\n  rotates to an \"unsnooze\" action and shows the wake-up time\n  in its tooltip.\n- Optimistic patch: in Inbox view, snoozing pops the thread\n  out of the list and advances to the next thread (the same\n  feel as Archive).\n\n### Test coverage (+8, 282 total mailbox-module tests)\n- snooze sets `snooze_until` + drops from Inbox; appears\n  under `snoozed` folder.\n- past timestamp + 365-day ceiling rejected.\n- unsnooze clears + restores.\n- wake-cron clears elapsed snoozes; no-op for future ones.\n- Inbox tolerates an elapsed snooze even before the wake\n  cron has run (predicate-level guard).\n- policy denial without `mailbox:thread:write:own`.\n\n## Net\n\nStaging unblocked; users can snooze a thread to next week\nwith two clicks; the Snoozed folder shows everything waiting.\nShared inboxes inherit the personal action surface for now\n(the team-mate-vs-me semantic for shared snooze lands in a\nfollow-up).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.438Z","updatedAt":"2026-06-16T04:14:07.438Z"},{"id":"c2726a90-4363-4e32-a332-bcfff650fa84","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-purchase-plan-step-extracted","type":"changed","scope":"web","summary":"PurchasePlanStep + GatewayPicker + helpers extracted from signup.tsx (1315 → 876 lines).","body":"Seventh slice of the signup.tsx step-by-step extraction. Step 4.5 —\nchoose a plan with the plan catalog, the payment-provider gateway\npicker, the platform-configured auto-skip, and the\n`payments.signup.create_plan_checkout` hand-off that redirects the\nbuyer to the self-hosted checkout — moves to\n`apps/web/src/components/signup/purchase-plan-step.tsx` (407 lines)\nalong with `GatewayPicker`, `formatPlanPrice`, `useCallbackToModules`,\nand the `PublicPlan` / `SignupProvider` types.\n\nThe `PublicPlan` type is re-exported because ModulesStep (still in\nsignup.tsx) imports it to gate the catalog by the chosen plan.\n\nSame `saas.plan.list_public` + `payments.platform.configured` +\n`payments.platform.providers.list_active_for_signup` reads, same\nauto-skip when no sellable paid plans, same gateway picker\nsingle/multi shapes, same checkout hand-off behaviour. The component\ntakes the typed `SignupSearch` shape as a prop.\n\nsignup.tsx: 1315 → 876 lines. Remaining wizard slice: ModulesStep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:39.035Z","updatedAt":"2026-06-16T00:03:39.035Z"},{"id":"22f3bedc-ece2-41a3-9411-9e4ffbdafc1e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-workspace-step-extracted","type":"changed","scope":"web","summary":"WorkspaceStep + ChipPicker + SelectField extracted from signup.tsx (2050 → 1687 lines).","body":"Fifth slice of the signup.tsx step-by-step extraction. Step 4 (the\n\"name your workspace\" identity form with name, slug, primary use,\nteam size, industry) moves to\n`apps/web/src/components/signup/workspace-step.tsx` (359 lines) along\nwith its private `ChipPicker` + `SelectField` renderers and the\n`WorkspaceForm` type alias.\n\nThe slug auto-deriver (subscribes to the form-store, derives slug from\nname until the user takes manual control via any keystroke in the\nslug input) moves with the component intact. The live URL preview —\npulses to accent when the slug becomes valid — moves with it.\n\nBoth `ChipPicker` and `SelectField` are typed against `WorkspaceValues`\nso they're scoped to this step; no shared-renderer file needed.\n\nSame translation keys, same `WorkspaceStepSchema` validation, same\nslugify behaviour. The component takes the typed `SignupSearch` shape\nas a prop so it doesn't reach back into the route's `Route.useSearch()`.\n\nsignup.tsx: 2050 → 1687 lines. Remaining wizard slices: AccountStep,\nPurchasePlanStep, ModulesStep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:39.236Z","updatedAt":"2026-06-16T00:03:39.236Z"},{"id":"aec49428-b9c9-4f21-bad8-6be9ab6b6946","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"signup-modules-step-extracted","type":"changed","scope":"web","summary":"ModulesStep + ModuleCard + PresetChip extracted from signup.tsx (876 → 306 lines) — campaign complete.","body":"Eighth and final slice of the signup.tsx step-by-step extraction. Step\n5 — pick modules with quick presets (Sales / People / Projects /\nOperations / Everything), plan-locked catalog entries, demo-data\nopt-in, the org-create + setActive-with-retry + workspace prefs upsert\n+ setup-step-complete + demo-seed chain — moves to\n`apps/web/src/components/signup/modules-step.tsx` (522 lines) along\nwith its private `PresetChip` and `ModuleCard` consumers.\n\nSame module gate logic (`planAllowedModuleSet`,\n`isModulePlanLocked`, `filterModulesByPlan`), same Better-Auth\n`organization.create` idempotency via `draft.createdOrgId`, same\ntwice-retry `setActive`, same fail-inline-on-prefs-failure, same\nnon-fatal demo-data seed, same navigation to the invite step. The\ncomponent takes the typed `SignupSearch` shape as a prop.\n\nsignup.tsx: 876 → 306 lines. **Campaign complete.**\n\nEnd-to-end the route file went from 3142 → 306 lines (-2836 / 90%\nreduction). The 8 extracted components live in\n`apps/web/src/components/signup/`:\n\n  - back-to-intent.tsx       22 lines\n  - back-button.tsx          24 lines\n  - intent-step.tsx         185 lines\n  - invitation-shared.ts     84 lines\n  - join-step.tsx           255 lines\n  - account-step.tsx        359 lines\n  - workspace-step.tsx      359 lines  (ChipPicker + SelectField inlined)\n  - purchase-plan-step.tsx  407 lines  (GatewayPicker inlined)\n  - modules-step.tsx        522 lines  (PresetChip + ModuleCard inlined)\n  - complete-step.tsx       219 lines  (DirectLandingRecovery inlined)\n  - invite-team-step.tsx    318 lines  (InviteRow inlined)\n\nsignup.tsx now contains: the route definition, SignupWizard host,\nStepRouter, WizardShell with the progress bar, and VerifyInboxNotice.\nThat's it. The wizard's actual content is now split per concern,\neach step independently navigable, and each component reasonably\nsized for review.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T00:03:39.069Z","updatedAt":"2026-06-16T00:03:39.069Z"},{"id":"8cfdac43-41c7-4d96-9faf-3227de3ecb67","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"fix-routing-tab-crash","type":"fixed","scope":"web","summary":"/saas/platform/email/routing no longer crashes when the queue has stuck rows (ageHours/recipientTo type drift).","body":"The Routing tab crashed with\n`Cannot read properties of undefined (reading 'toFixed')` whenever the\nplatform queue had at least one row in `queued`/`retrying`/`sending`.\n\nTwo field-name drifts between the action's `DiagnoseOutput` wire schema\nand the page's local `DiagnoseResult` type were the cause:\n\n- `oldestQueued.ageMinutes` (wire) vs `oldestQueued.ageHours` (UI) —\n  reading the missing field returned `undefined`, then `.toFixed(1)`\n  threw and the whole tab unmounted to the error boundary.\n- `recentFailures[*].recipientTo` and `failedAt` — neither exists on the\n  wire shape (the schema returns `updatedAt` and no recipient). Would\n  have been a follow-up crash on `recipientTo[0]` once the queue had any\n  `failed` rows.\n\nThe Routing tab is the surface admins go to when verify-emails aren't\nlanding, so it failing precisely when the queue HAS stuck rows was the\nworst-possible failure shape.\n\nFix: align the local types with the wire schema, format `ageMinutes`\ninto a human-readable label (minutes under 2h, hours otherwise) with a\ndefensive guard for malformed payloads, and guard `recipientTo?.[0]`\nwith an em-dash fallback when absent.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:06.227Z","updatedAt":"2026-06-16T04:14:06.227Z"},{"id":"2c6003fd-834b-4315-98a7-2a4704c52bce","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-dynamic-config","type":"added","scope":"mailbox","summary":"Mailbox OAuth client credentials + state-token secret are now editable from /saas/mailbox at runtime — no env vars, no redeploy.","body":"Replaces three env-var-only configuration knobs with a proper\ndynamic config layer stored in `platform_settings`. Operators\npaste credentials in `/saas/mailbox` and they take effect\nimmediately. The legacy env vars continue to work as a\nback-compat fallback for already-running deployments.\n\n## Storage\n\nNew `platform_settings.mailbox_config` jsonb column. Shape:\n\n```ts\n{\n  gmailOauth: {\n    clientId?: string;\n    clientSecretEncrypted?: string;  // @helios/email AES-GCM envelope\n    updatedAt?: string;\n  };\n  graphOauth: {\n    clientId?: string;\n    clientSecretEncrypted?: string;\n    tenant?: string;                 // default 'common'\n    updatedAt?: string;\n  };\n  stateSecretEncrypted?: string;\n  imapHostAllowList?: string[];\n  updatedAt?: string;\n  updatedBy?: string | null;\n}\n```\n\nSecrets at rest use the same AES-GCM envelope as\n`ai_providers`, `email_providers`, and\n`mailbox_accounts.oauth_tokens_encrypted`.\n\n## Resolver\n\n`modules/mailbox/src/lib/mailbox-config.ts` —\n`resolveMailboxConfig(db)` reads the jsonb once and falls back\nto env vars when fields are unset. Resolution order:\n\n1. Explicit value passed by the test (when applicable).\n2. `platform_settings.mailbox_config.{...}` — DB.\n3. `GOOGLE_OAUTH_CLIENT_ID` / `MICROSOFT_OAUTH_CLIENT_ID` /\n   `MAILBOX_OAUTH_STATE_SECRET` — back-compat env.\n4. (state-secret only) `BETTER_AUTH_SECRET`-derived fallback\n   from `state-token.ts`.\n\n## Actions\n\nThree new actions in `modules/platform/src/actions/mailbox-config.ts`:\n\n- `platform.mailbox.read_public` — auth'd; returns just\n  `{configured}` flags. Used by the connect screen to decide\n  which buttons to surface.\n- `platform.mailbox.read_admin` — root only; returns\n  unencrypted client ids + `secretSet: boolean`. Never returns\n  the plaintext secret.\n- `platform.mailbox.update` — root only; partial patch. Empty\n  string on a `clientSecret` field clears it. New plaintext\n  values are encrypted before persist via\n  `@helios/email.encryptString`. Marked `dangerous: true`.\n\n## UI\n\nNew route `/saas/mailbox`:\n- Gmail OAuth card — Client ID + Client Secret input + setup\n  guide pointing at the Google Cloud Console redirect URI.\n- Graph OAuth card — same + a Tenant id field (default\n  `common`, paste a specific GUID to lock to one tenant).\n- State-token secret card — write-only. Defaults to the\n  `BETTER_AUTH_SECRET`-derived value when blank.\n- IMAP host allow-list — multi-line textarea (one host pattern\n  per line). Reserved for the upcoming M1g IMAP connect flow.\n\nLinked from the SAAS rail's Developer group.\n\n## Wiring\n\n- `connect.ts` calls `resolveMailboxConfig(ctx.db)`, picks\n  the right clientId, surfaces a friendly\n  `dependency_failed` error when neither source has it.\n- `connect-complete.ts` injects the resolved\n  `clientId/clientSecret/tenant` into `exchangeOAuthCode`.\n- `buildOAuthAuthUrl` becomes a pure function (was reading\n  env directly). Tests updated.\n- `signStateToken` honours the resolved state secret when set;\n  otherwise falls through to env / `BETTER_AUTH_SECRET`.\n\n## Migration\n\n`packages/db/drizzle/0274_0275_platform_mailbox_config.sql`\nadds the jsonb column. Idempotent.\n\n## Test coverage\n\n`connect.test.ts` rebuilt around the PGlite harness so the\nresolver runs. Tests cover happy path (env-var fallback),\nBETTER_AUTH_URL fallback, returnTo + loginHint propagation,\nGraph URL shape, and the new `dependency_failed` path when\nneither env nor DB has the client id.\n\n284 mailbox-module tests pass.\n\n## Net\n\nTwo-click setup:\n1. Root admin pastes Google Cloud Console credentials in\n   `/saas/mailbox`.\n2. Tenants click \"Connect Gmail\" in\n   `/settings/mailbox` and the OAuth dance works\n   immediately — no deploy, no env-var edits.\n\nOrg-level overrides (BYO Google Workspace OAuth app per org)\nand IMAP/SMTP connect (M1g) land in follow-ups using the same\nstorage shape.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:06.899Z","updatedAt":"2026-06-16T04:14:06.899Z"},{"id":"345765e7-2261-4992-a26a-9eb7c730bab0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"passkey-counts-as-2fa","type":"fixed","scope":"web","summary":"TwoFactorCard + SecurityScoreCard now recognise a registered passkey as a second factor.","body":"The user reported: \"Setup two-factor authentication shows 3 methods —\nemail codes / authenticator / security key+passkey. It works if you\nset up the authenticator method, upon passkey or other method setup\ncompleted it still doesn't recognize them as 2FA added.\"\n\nTwo surfaces both read only `user.twoFactorEnabled` (the Better-Auth\nTOTP bit) when deciding whether 2FA was \"on\":\n\n- **SecurityScoreCard's \"Two-factor authentication\" row** — score\n  card at the top of `/settings/security`. Stayed on `Enable →`\n  even when a passkey was registered, then docked the security\n  score by 20% for what was already a phishing-resistant second\n  factor.\n- **TwoFactorCard's enabled state** — the body card itself. Showed\n  \"Not enabled — Password is the only factor right now\" with the\n  3-option picker, while the Passkeys card directly below clearly\n  listed the user's registered passkey.\n\nFix: both surfaces now treat `totpEnabled OR registeredPasskey ≥ 1`\nas \"second factor present\". The TwoFactorCard subtitle differentiates\n(\"Enabled — authenticator\" vs \"Enabled — passkey\") so the user knows\nwhich factor is satisfying the requirement, and offers an \"Add\nauthenticator\" ghost button when only a passkey is set up (defence-\nin-depth + recovery if the passkey device is lost). The Manage button\nstill appears only when TOTP is actually enabled — there's nothing\nto manage on the passkey side from this card; the Passkeys card\nbelow owns that.\n\nNet result for the user's screenshot: the security score climbs from\n4 of 5 (STRONG) to 5 of 5, and the TwoFactorCard correctly reflects\n\"Enabled — passkey\" with a hint pointing at the Passkeys card for\nmanagement.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.551Z","updatedAt":"2026-06-16T04:14:07.551Z"},{"id":"8d455079-1730-4f7d-9949-5f920d1dccf5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-routing-admin","type":"added","scope":"storage","summary":"Added per-org storage routing admin — /saas/storage/routing grid + storage.routing.list / get_for_org read actions.","body":"Phase 3B-2 of the unified Storage + Drive plan. Root operators now\nhave a visible surface for changing where each tenant's bytes land.\n\nTwo new read actions on `@helios/storage-module/actions`:\n\n- `storage.routing.list { q?, customizedOnly, offset, limit }` —\n  paginated grid feed. Joins `storage_org_routing` × `organizations`\n  × `storage_profiles` in one round-trip so the UI renders default-\n  profile names + per-purpose override badges without N+1. `q` does\n  case-insensitive substring match on org name or slug; `customizedOnly`\n  filters to rows whose default ≠ platform default OR whose\n  per-purpose map has any keys.\n- `storage.routing.get_for_org { orgId }` — single org's routing +\n  every non-archived profile (the picker source). Powers the per-org\n  edit modal.\n\nAdmin UI at `/saas/storage/routing`:\n\n- Searchable, filterable, paginated grid (25-per-page, max 100).\n- Per-row Edit button opens a modal: pick default profile + add /\n  remove per-purpose overrides from ~10 common purpose values\n  (avatar, mailbox, hrm_document, sales_invoice, etc.).\n- Override delta logic: keys you remove from the modal's local\n  working copy get sent to `storage.profile.assign` as `null`\n  (clears the override); keys you keep get sent with the new\n  profile id (writes or replaces).\n- Cross-link from the existing `/saas/storage` page header.\n- Sidebar entry under SaaS → Developer.\n\nThe companion write side already exists on `storage.profile.assign`\n(Phase 3A). That action is now flagged `dangerous: true` because\ncross-tenant routing changes silently redirect future writes for\nan org's avatars / mailbox / HRM contracts to a different provider\nprofile — potentially a different cloud account / region / encryption\nposture — at least as impactful as `storage.profile.archive`. The\nMCP / AI surface now gates it behind the standard confirmation prompt.\n\n13 schema-validation vitest cases + 9 PGlite integration tests\n(77 storage unit + 30 integration tests pass; sales suite unchanged\nat 234 / 75).\n\nAdversarial-verify workflow (3 reviewers × 24 candidate findings →\n2 survived skeptic verify): the `dangerous: true` flip above plus\ndropping two dead `void`-silenced imports.\n\nThe remaining Phase 3 work (3D: BYOB credential KMS-wrap + weekly\nverify cron + driver head/list) lands separately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.996Z","updatedAt":"2026-06-16T04:14:09.996Z"},{"id":"f2f23310-bd8a-4cef-a81c-ef2ea5355676","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-access-registry","type":"added","scope":"storage","summary":"Producing modules can now register per-object read-access verifiers; storage.object.get_url + get_stream consult them after the org gate. Q15 personal-scope fence is hard-enforced.","body":"Phase 1 commit 5 of the email + mailbox → unified storage migration.\nTurns CLAUDE.md invariant #3 (\"per-object access control is\ndelegated\") from documentation into running code, and adds the Q15\npersonal-scope fence (invariant #14).\n\n## What changed\n\n- **New access registry** at `modules/storage/src/lib/access-registry.ts`:\n  - `registerStorageReadAccessVerifier(ownerModule, verifier)` —\n    producing modules (email, mailbox, hrm, sales, …) call this at\n    boot to plug their per-object access check into the storage\n    layer.\n  - `verifyStorageReadAccess({row, actor, logger})` — the helper\n    `storage.object.get_url` / `get_stream` consult after the org +\n    scan_state gates pass.\n  - Test-only `__resetStorageReadAccessRegistryForTesting()` +\n    `__listStorageReadAccessModulesForTesting()` so integration\n    tests can isolate.\n\n- **`storage.object.get_url` + `get_stream` wired through the\n  registry.** After the existing org gate (skipped for personal-\n  scope rows) + the scan-state gate, the action calls the registry's\n  decision helper. A denial bubbles up as `policy_denied` carrying\n  the verifier's reason. Module-owned objects with no registered\n  verifier are allowed-with-warning — opt-in tightening, the org gate\n  is the floor.\n\n- **Q15 personal-scope fence**: when a storage row's `org_id` matches\n  the platform sentinel\n  (`PLATFORM_PERSONAL_SCOPE_ORG_ID`), the caller MUST be the\n  `owner_user_id`. No exceptions — `platform:storage:usage:read`\n  does NOT bypass. Enforced inside the registry's decision helper so\n  it can't be skipped by a misconfigured action handler. CLAUDE.md\n  invariant #14.\n\n- **User-owned objects** get a default user-level check inside the\n  registry: only the `owner_user_id` reads, unless the caller holds\n  `platform:storage:usage:read`. A producing-module verifier can\n  still tighten further (e.g. HRM employee documents that require\n  manager sign-off).\n\n- **Sentinel UUID fixed** to `00000000-0000-4000-8000-0000000000a1`\n  — was `00000000-0000-0000-0000-0000000000a1`, which fails the\n  Zod UUIDv1-v8 validator the action input schemas use.\n\n## Tests\n\n- `modules/storage/src/lib/access-registry.test.ts` (14 unit\n  cases): Q15 fence happy + denied (incl. platform-perm bypass\n  rejection); user-owned owner-only with platform-perm bypass for\n  non-owners; module-owned delegation + denial; unregistered\n  fallback warning routed via `warn` or `info`; last-write-wins\n  re-registration; `__list` + `__reset` helpers.\n\n- `modules/storage/src/actions/object-access-registry.integration.test.ts`\n  (2 PGlite cases through the real action handlers): personal-scope\n  row denies non-owners including platform admins; a tightening\n  verifier denies what would otherwise pass the org gate.\n\n98/98 unit + 55/55 integration; 299/299 mailbox; 270/270 email.\n\n## Reference\n\n- Registry: [modules/storage/src/lib/access-registry.ts](../../modules/storage/src/lib/access-registry.ts)\n- Wiring: [modules/storage/src/actions/object.ts](../../modules/storage/src/actions/object.ts) (`getUrl`, `getStream` handlers)\n- Sentinel: [modules/storage/src/lib/sentinels.ts](../../modules/storage/src/lib/sentinels.ts)\n- CLAUDE.md invariants #3, #14","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.727Z","updatedAt":"2026-06-16T13:46:03.727Z"},{"id":"313c25d6-0849-4b9d-bb8d-9917d883a130","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-roadmap-admin-card-polish-and-tab-state","type":"changed","scope":"roadmap","summary":"Admin kanban cards link into the new feature detail page, pick up the public-side polish, and the active tab now persists in the URL.","body":"The admin kanban / by-category / timeline tabs all render `KanbanCard`, which was a non-interactive div with no way to drill into the underlying feature. Cards are now `Link`s that route into `/saas/roadmap/$featureId`, and they inherit the same Linear-style status-edge accent stripe + hover lift + tighter typography we just shipped on the public roadmap so the admin and customer-facing surfaces read the same visual language.\n\nThe active admin tab (Triage / Roadmap / By category / Timeline / Duplicates / Settings) now persists in the URL as `?tab=...`, so navigating into the detail page and back lands the operator on the same tab they left — not always Triage.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.268Z","updatedAt":"2026-06-16T04:14:09.268Z"},{"id":"e47a4bb3-e089-4591-9bbb-8cef4730bfed","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"public-roadmap-detail-page-polish","type":"changed","scope":"roadmap","summary":"Public feature detail page picks up a status-edge accent stripe header and shows who requested it / last touched it.","body":"The `/help/roadmap/$slug` header card now wears the same left-edge status accent stripe the kanban / list / timeline cards use, framing the feature in a single visual hierarchy with the views that lead into it.\n\nBelow the summary, the header surfaces a credit row: \"Requested by &lt;avatar&gt; Name\" and (when distinct) \"Last touched by &lt;avatar&gt; Name\". The data was already on the wire via the public action; the header just renders it now.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.547Z","updatedAt":"2026-06-16T04:14:07.547Z"},{"id":"83b7e05b-a48d-41ca-adef-40b669c2ffe8","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"public-roadmap-list-timeline-redesign","type":"changed","scope":"roadmap","summary":"Public roadmap list now true pure-list rows, timeline becomes horizontal milestone swimlanes, cards get an enterprise-grade polish, and the list undercount bug is fixed.","body":"The List tab on `/help/roadmap` was rendering as a 2-column card grid and silently hiding any feature in `open` or `under_review` status — so a draft visible on the Kanban could vanish from the List. The List view is now a true single-column table-like layout with column headers (Votes · Feature · Status · Category · Activity) and explicitly passes all six statuses to `feature.list_public` so the count never falls behind the Kanban / Timeline views.\n\nThe Timeline tab is rebuilt as a horizontal milestone swimlane: Shipped → Now → Next → 2026 Q3 → … → Later → In triage → Declined. Each column has an accent stripe matching its bucket (green / amber / blue / zinc / red), a count badge, and a thin time-axis indicator at the top of the section so visitors read the layout left-to-right as the product moves forward — mirroring how Linear, Height, and Productboard present roadmaps.\n\nFeature cards picked up a left-edge status accent stripe (Linear-style), tighter typography, a hover-shadow lift, and a smaller upvote tile with a stateful filled/outline `ArrowFatUp` so voted items remain unmistakably marked.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.577Z","updatedAt":"2026-06-16T04:14:07.577Z"},{"id":"b119c193-01dd-4099-969b-21316f0e7421","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-preferences-card-affordance","type":"changed","scope":"roadmap","summary":"/help/roadmap/preferences cadence cards show a per-card \"Saving…\" state and the unsubscribe action uses the shared danger Button.","body":"Clicking a cadence card on `/help/roadmap/preferences` used to just disable the whole row; the user couldn't tell which option they'd clicked while the network call was in flight. Each card now carries an \"aria-busy\" + an inline \"Saving…\" tag while its specific save is pending, and the other cards dim subtly to make the active card obvious.\n\nThe \"Unsubscribe from roadmap emails\" affordance now uses the shared `<Button variant=\"danger\" size=\"sm\" loading>` primitive instead of a bespoke `<button>` with hand-rolled hover styles, so it inherits the danger variant's hover/focus chrome + the unified loading spinner the rest of the app already uses.\n\nThe check inside the active radio dot now uses `text-[var(--fg-on-accent)]` instead of hardcoded `text-white`, so it stays legible against any operator-configured accent colour (the hardcoded white blew out against light accent tokens like amber).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.738Z","updatedAt":"2026-06-16T04:14:07.738Z"},{"id":"1f8f6c32-624b-46d4-a575-ea4cd19517d5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-profile-verify","type":"added","scope":"storage","summary":"Added storage.profile.verify — roundtrip self-test (PUT → fetch → DELETE) with per-step timings.","body":"Phase 3C of the unified Storage + Drive plan. Operators can now\nroundtrip-test a profile against its provider before tenants write\nreal bytes. Catches credential / endpoint / bucket / CORS\nmisconfiguration on BYOB onboarding and as part of the weekly\nre-verify cron (Phase 3D wires the cron).\n\n`storage.profile.verify { id }` runs three steps against the profile:\n\n1. PUT a tiny test object under `_platform/bcheck/<uuid>` (reserved\n   prefix; cannot collide with real tenant data)\n2. Fetch it back via a presigned GET URL and compare bytes\n3. DELETE the test object (always attempted, even when fetch failed,\n   so we leave nothing behind)\n\nPer-step `{ step, ok, durationMs, error? }` records returned so the\noperator dashboard can distinguish slow vs broken. On full success,\nflips `status='verified'` + `last_verified_at=now()` + clears\n`verify_error`. On any failure, sets `status='verify_failed'` + a\ndescriptive `verify_error`. Emits `storage.profile.verified` either way.\n\nAlso extends the StorageClient driver surface with `deleteObject(key)`\n(both the s3-compatible and local drivers implement it; the fake gets\nmatching `setFailNext('deleteObject', ...)` support). The S3 driver\nuses `DeleteObjectCommand`; the local driver `fs.unlink` with ENOENT\nswallowed so the operation is idempotent (matches S3 semantics).\n\n6 new vitest cases (3 schema validation + 3 PGlite integration tests).\nThe integration tests stand up an inline `node:http` server that\nmirrors apps/web's storage-local proxy — verifies the HMAC sig and\nserves bytes from `STORAGE_LOCAL_ROOT` — so the verify action's\n`fetch(presignedUrl)` actually resolves against the test's local\ndriver. 64 storage unit tests + 18 storage integration tests all pass.\n\nHEAD + LIST steps land with the broader Driver-interface buildout in\nPhase 3D alongside the BYOB-credential KMS-wrap path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.508Z","updatedAt":"2026-06-16T04:14:09.508Z"},{"id":"95f70956-5e21-4a39-b2e1-0eeed353a8df","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-audit-quick-wins","type":"changed","scope":"roadmap","summary":"Closes audit items E14 + E15 + E17 — show/hide signing-secret toggle, focusable Possibly-related disclosure, Clear-all-filters CTA on public board.","body":"Three small but visible UX gaps from the module audit close together:\n\n- **E17.** When filters on `/help/roadmap` (List tab) returned no rows, the empty state said \"Try clearing filters\" but didn't offer a button. Now: the empty-state copy distinguishes \"no features published yet\" from \"no features match these filters\", and the filter-narrowed case ships an inline \"Clear all filters\" CTA that resets status / category / search back to defaults in a single click.\n- **E14.** The Linear webhook signing-secret field on `/saas/roadmap` Settings was a plain `type=\"password\"` with no peek affordance, so operators pasting a long opaque token had no way to verify it matched what Linear's dashboard shows. The field now carries an eye / eye-slash toggle in the trailing slot with `aria-pressed` for screen-reader state.\n- **E15.** The \"Possibly related\" disclosure on the submit form was keyboard-reachable but visually had no focus indicator and no descriptive label. Adds a focus-visible accent ring, hover/focus background, and an `aria-label` that announces the match count + how to expand.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:07.767Z","updatedAt":"2026-06-16T04:14:07.767Z"},{"id":"b9f90cdb-ef58-4ca0-9292-266767aa27cc","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-roadmap-detail-page-and-create-form","type":"added","scope":"roadmap","summary":"New admin feature-detail page at /saas/roadmap/$featureId plus a sectioned, preview-driven create form.","body":"The admin Triage table used to open every feature edit inside a cramped right-hand Sheet, with no room for the full description, no audit trail, no signal counts at a glance, and no public-URL preview. Clicking a feature title now opens a dedicated detail page at `/saas/roadmap/$featureId` with a status pill + \"On public roadmap\" badge in the header, lifecycle quick actions (status flips + promote/demote), and three side cards: Description (use case, body, tags, decline reason), Metadata (category, visibility, dates, external tracker), Signal (voters, weighted demand, followers, comments, trending score), and Audit (filed by, last touched by, link back to Triage).\n\nThe \"New feature\" composer is rewritten into a sectioned, preview-driven form. Sections: \"What's being asked\" (title with slug preview + summary with char counter), \"Why customers want it\" (use case), \"Details\" (body), \"Triage\" (category, status, target label), \"Visibility & exposure\" (visibility + roadmap-visible toggle), and a \"Live preview\" card that mocks how the feature will render on the public roadmap. Char counters surface on the long fields (title 200, summary 500, use case 2000, body 20000), submit shows a \"Creating…\" state, and a footer bar telegraphs whether the entry will go live publicly or stay in admin triage.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.230Z","updatedAt":"2026-06-16T04:14:09.230Z"},{"id":"3f41fd7d-264b-43a9-a976-99421a8a6fd1","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-roadmap-error-surface-and-tokens","type":"fixed","scope":"roadmap","summary":"Admin /saas/roadmap surfaces query errors instead of looking empty, and the brand-token fix is extended to the admin file.","body":"The admin Triage / Kanban / By-category / Timeline tabs used to render an empty Table or \"No features yet\" state whenever the underlying `list_admin` call errored — operators had no way to tell a real empty roadmap apart from a silent failure. A new `ListErrorState` component now surfaces the error message + a Try again button on every list-driven tab.\n\nThe same `--accent-primary` non-existent CSS variable rename done for `/help/roadmap` files also lands on `apps/web/src/routes/saas/roadmap.tsx`. The upvote icon on admin kanban cards was rendering invisible (white-on-white) because the old broken token resolved to `unset`; it now picks up the canonical `--accent` token and turns amber.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.647Z","updatedAt":"2026-06-16T04:14:09.647Z"},{"id":"f780108c-5915-409f-b94b-1a2d2412ddc0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-roadmap-detail-page-decline-dialog","type":"changed","scope":"roadmap","summary":"Detail-page quick-action bar groups transitions by intent and uses a proper decline-reason dialog instead of window.prompt.","body":"The lifecycle quick-action bar on `/saas/roadmap/$featureId` used to render every other status as a flat row of identical secondary buttons, then call `window.prompt` for the decline-reason. Operators got an unstyled, copy-paste-broken browser dialog with no multiline support, no spelling assist, and no in-app theme.\n\nThe row now reads as three semantic groups separated by hairline dividers: **Move to → …** (forward progression — Under review / Planned / In progress / Shipped), then **Reopen** (only when the feature is in a terminal state), then a destructive-styled **Decline…** button, then the **Promote / Demote** visibility flip on its own. The decline action opens a styled `PromptDialog` with a multi-line text area, pre-filled with the prior resolution notes, and a \"Declining…\" pending state. The legacy decline-via-status-flip in the secondary row is gone.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.678Z","updatedAt":"2026-06-16T04:14:09.678Z"},{"id":"55ea5f3c-00f5-40ad-9e6d-656591121f05","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-admin-ui","type":"added","scope":"web","summary":"Added the /saas/storage admin page — list, create, verify, archive storage profiles.","body":"Phase 3B of the unified Storage + Drive plan. Root operators get a\nvisible surface for the seven storage profile actions shipped in\nPhases 3A + 3C.\n\nThe page at `/saas/storage` shows every storage_profiles row with\nname, driver, bucket, status badge (verified / verify_failed /\nprovisioning / archived), last verified timestamp, and inline verify\n+ archive buttons.\n\n- **Verify** invokes `storage.profile.verify` and toasts the total\n  roundtrip time on success or the per-step error on failure.\n- **Archive** confirms via the standard ConfirmDialog, denies on the\n  platform default profile (matches the action-layer guard), then\n  refreshes the list.\n- **New profile** opens a Modal with name / driver / region /\n  bucket / endpoint fields. Driver selector covers `s3` (AWS / R2 /\n  B2 / Wasabi / MinIO / DO) + `local` (dev-only). Server validation\n  errors surface via FormErrorBanner.\n- **Show archived** toggle exposes the archived-rows view for audit.\n\nAdded a \"Storage\" entry to the SaaS sidebar nav under Developer.\n\nPer-org routing assignment + verify-with-step-breakdown drawer land\nin 3B-2. The KMS-wrap path + weekly verify cron + driver head/list\nship in Phase 3D.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.702Z","updatedAt":"2026-06-16T04:14:09.702Z"},{"id":"ef75ce59-3caf-4210-86db-5d83eeebe85c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-platform-only-and-3d1","type":"changed","scope":"storage","summary":"Platform-only storage policy + 5-step verify + plan-tiered storage caps backfilled.","body":"Three threads land together because they all touch the same surface.\n\n### BYOB is platform-admin-only (2026-06-16 policy directive)\n\nTenants do not bring their own bucket. Operators provision every\nstorage profile — platform-managed AND tenant-dedicated BYOB — via\nthe root surface at `/saas/storage`. Tenants consume the platform-\nprovided storage feature, capped + gated by plan.\n\n- Dropped `storage:profile:byob:manage` tenant permission entirely.\n- `storage.profile.create` policy collapsed to `profileManagePolicy`\n  (`platform:storage:profile:manage`). BYOB profiles still set\n  `ownerOrgId` so they're tenant-isolated at the routing layer,\n  but the creation flow is operator-driven.\n- `storage.profile.update` same — both platform + BYOB profiles\n  edit through the same admin perm.\n- Integration tests rewritten: tenant context now confirms denial\n  on both create + create-for-own-org paths; root path stays.\n- Spec doc + module CLAUDE.md updated.\n\n### 3D-1: Driver `head` + `list` + 5-step verify\n\nExtended `StorageClient` with two ops:\n\n- `headObject(key)` — metadata-only read; returns `null` on missing\n  (matches S3's NoSuchKey semantics). S3 strips the etag quotes so\n  the local + fake + s3 driver shapes match.\n- `listObjects({ prefix, cursor?, limit? })` — paginated key listing;\n  S3 uses ListObjectsV2 + ContinuationToken; local does a sorted\n  filesystem walk under the prefix's directory; fake does a sorted\n  prefix-match against the in-memory map.\n\n`storage.profile.verify` now runs the full canonical BYOB\nself-test sequence: PUT → HEAD → fetch → DELETE → LIST. Each step\nis timed; the LIST step confirms the just-deleted key is gone\n(catches credentials that grant Put/Delete but not List — the\nSnowflake / Databricks BYOB validation gotcha).\n\n### Plan-tiered storage caps backfilled\n\nSeven new feature-catalog entries:\n\n  storage_file_count_max           free 1k  / starter 50k    / business 500k    / enterprise unlimited\n  storage_per_file_max_bytes       free 25M / starter 100M   / business 1G      / enterprise 10G\n  storage_egress_gb_month          free 5GB / starter 100GB  / business 1TB     / enterprise unlimited\n  storage_versioning_enabled       free no  / starter no     / business yes     / enterprise yes\n  storage_retention_days           free 7d  / starter 30d    / business 90d     / enterprise 365d\n  storage_addon_allowed            free no  / starter yes    / business yes     / enterprise yes\n  storage_object_lock_allowed      free no  / starter no     / business no      / enterprise yes (WORM)\n\nMigration `0276_0277_storage_plan_features_backfill.sql` writes the\ndefaults onto every existing `saas_plans` row idempotently. Drift\nguardrail test updated to pin the new canonical defaults.\n\n### Tests\n\n- @helios/storage         14 unit (driver round-trip + dispatch)\n- @helios/storage-module  78 unit + 31 integration\n- @helios/saas           411 unit (drift guardrail + 410 others)\n- @helios/web            typecheck clean on /saas/storage routes","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:15:09.703Z","updatedAt":"2026-06-16T04:15:09.703Z"},{"id":"a5575e79-2c45-4420-ac29-c07919a842bb","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-dedup-aware-put","type":"changed","scope":"storage","summary":"storage.object.put now dedupes by sha256 within an org — identical bytes refcount-bump an existing live row instead of creating a duplicate. Output gains isNew boolean.","body":"Phase 1 commit 2 of the email + mailbox → unified storage migration.\nActivates the dedup invariant the schema lock (commit\n`772ef9b9`) put in place.\n\n## What changed\n\n`storage.object.put` (server-side direct upload) now computes the\nsha256 of the supplied bytes (or uses the caller-provided one), and:\n\n- **Pre-checks** for an existing live row with matching `(orgId,\n  sha256, deleted_at IS NULL)`. On hit: refcount++, no provider\n  write, no quota gate, no meter event, no `storage.object.uploaded`\n  event. Returns the existing row's `objectId` with `isNew: false`.\n- **Race fallback**: concurrent callers racing through the pre-check\n  window both write to the provider, then one INSERT loses on the\n  `storage_objects_org_sha_uniq` partial unique index with SQLSTATE\n  `23505`. The loser re-selects the winning row and refcount-bumps\n  it. The duplicate bytes at the provider become orphan blobs the\n  Phase 8 orphan-sweep cron reaps.\n- **Returns `isNew: boolean`** in the output. Producing modules use\n  this to short-circuit downstream work that only makes sense for\n  genuinely new bytes (AV scan triggers, \"first upload\" notifications).\n\nOrgs are isolated — the same bytes in a different org write a new\nrow. Soft-deleted rows don't lock content out — re-PUT after delete\ninserts a fresh row.\n\n## Why\n\nThe storage module's invariant #6 said \"dedupe by content hash\nwithin an org\" but only a non-unique helper index existed. The\ndedup logic was documented but never wired. This commit makes the\ninvariant real:\n\n- Email outbound attaches the same logo to 100 invoices → 1\n  storage object, refcount 100.\n- Email inbound webhooks deliver the same MIME forwarded to two\n  aliases → 1 raw_mime object, refcount 2.\n- Mailbox lazy mirror sees the same inline image in two messages\n  → 1 cached object.\n\n## Caller impact\n\n`storage.object.put` callers that ignored the previous output's\nextra field (none, since `isNew` is new) keep working. Callers that\nWANT to react to dedup (skip notification on `isNew: false`, etc.)\ncan opt in.\n\nSales `pdf-storage-cache` re-render test updated to reflect the new\nbehaviour — identical bytes now collapse to one storage_objects row\nwith refcount=2 instead of two separate rows.\n\n## Tests\n\n`modules/storage/src/actions/object-dedup.integration.test.ts` —\n5 PGlite cases:\n- first PUT writes a new row, returns isNew=true\n- second PUT of same bytes in same org refcount-bumps, returns isNew=false\n- PUT of same bytes in different org writes a separate row\n- after soft-delete, re-PUT inserts a fresh row\n- caller-supplied sha256 is honoured (trusts the caller)\n\nPlus the updated sales `pdf-storage-cache` test exercises the same\npath through the production caller.\n\n299/299 mailbox + 270/270 email + 80/80 storage unit + 45/45 storage\nintegration (40 prior + 5 new dedup) + 3/3 sales pdf-storage-cache.\n\n## Reference\n\n- Action: [modules/storage/src/actions/object.ts](../../modules/storage/src/actions/object.ts)\n- Schema: [modules/storage/src/schemas/object.ts](../../modules/storage/src/schemas/object.ts) (PutOutput gained `isNew`)\n- Migration that enables this: `0277_0278_storage_email_lifecycle_lock.sql` (partial unique index)\n- D-11 in [docs/plans/UNIFIED_STORAGE_AND_DRIVE/10_OPEN_DECISIONS.md](../../docs/plans/UNIFIED_STORAGE_AND_DRIVE/10_OPEN_DECISIONS.md)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.758Z","updatedAt":"2026-06-16T13:46:03.758Z"},{"id":"ffb325ef-df89-4c56-9058-dcf2a3f22180","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"action-api-dependency-failed-502","type":"fixed","scope":"web","summary":"Action API maps dependency_failed → 502 Bad Gateway (was falling through to 500), so upstream failures (IMAP timeout, Stripe down, S3 unreachable) stop masquerading as Helios bugs.","body":"A staging user reported a 500 from `mailbox.account.connect_imap`\nwith body `IMAP failed: Failed to establish connection in required\ntime`. The IMAP probe correctly fail-fast'd via the 20s ceiling\nshipped at `15ffacda` and the action returned `dependency_failed`\n— but the API's `statusFor` mapping didn't enumerate that code,\nso it fell through to `default: 500`.\n\nThis impacted EVERY upstream-failure path system-wide, not just\nIMAP: Stripe outages, S3 timeouts, AI-provider down, mailbox\nprovider connection refused — all surfaced as 500 to nginx logs,\nstatus pages, and incident alerting. Operators chased non-\nexistent Helios bugs instead of the real upstream issue.\n\n## What changed\n\n[apps/web/src/server/api.ts](../../apps/web/src/server/api.ts):\n\n- Added `case 'dependency_failed': return 502;` — RFC 7231 §6.6.3\n  semantic match (\"we received an invalid response from an\n  upstream server\").\n- Added explicit `case 'internal_error': return 500;` — separates\n  intent from the default fallback so the file reads as \"every\n  code mapped on purpose, default is for schema-drift surfacing\"\n  rather than \"default = 500 catch-all\".\n- Exported `statusFor` so the mapping is testable + reusable\n  (oRPC adapter, MCP wrapper, etc. will need the same mapping).\n\n## Tests\n\n`apps/web/src/server/api-status-mapping.test.ts` — 11 cases\nlocking the contract:\n\n- 400 validation, 401 step-up, 402 quota, 403 policy, 404\n  not-found, 409 conflict, 429 rate, 500 internal_error,\n  **502 dependency_failed**, 503 service_unavailable\n- Unknown code falls through to 500 to surface future schema\n  drift loudly\n\n## Operator impact\n\n- Status page incident on the next IMAP / S3 / Stripe blip\n  triggers a 5xx alert as expected — but now distinctly 502\n  (\"upstream\"), not 500 (\"our bug\"). Tune your alerting\n  filters accordingly.\n- Client SPA error-handling that branched on `>= 500` still\n  treats 502 the same as 500 today. No client change required.\n- Audit log + structured logs were already correct (carry\n  `errorCode` independent of HTTP status); this fix only\n  affects the wire status.\n\n## Note on the second 500 in the same report\n\nThe user also reported `saas.announcement.list_active` returning\n500. That action's policy is `allow()` and the handler queries\n`saas_announcements` + a dismissal subquery — neither of which\nreturns `dependency_failed`. A 500 there is a genuine runtime\nexception (likely a missing column / null actor field / unhandled\nSQL error). Without staging logs the root cause isn't actionable;\nops should grep `errorCode=internal_error` + action name in the\nworker logs to surface the underlying stack trace.\n\n## Reference\n\n- Mapping: [apps/web/src/server/api.ts](../../apps/web/src/server/api.ts) `statusFor`\n- ActionErrorCode source of truth: [packages/actions/src/result.ts](../../packages/actions/src/result.ts)\n- Triggering action: [modules/mailbox/src/actions/connect-imap.ts](../../modules/mailbox/src/actions/connect-imap.ts) (timeout path; logic correct, only the HTTP wire status was wrong)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:28.264Z","updatedAt":"2026-06-16T16:34:28.264Z"},{"id":"0ebd2202-5314-4a4e-b607-c51108472bd5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"sales-pdf-storage-cache","type":"added","scope":"sales","summary":"Invoice PDFs are now cached in the unified Storage module on first render — first consumer of Phase 5.","body":"Phase 5 of the unified Storage + Drive plan — sales is the first\nconsumer to migrate onto the new Storage actions.\n\n`sales.invoice.render_pdf` now fires a best-effort `storage.object.put`\nafter the PDF renders, persisting the bytes under `purpose='sales_invoice'`\nwith deterministic idempotency key `sales.invoice.<id>.render`. The\nrender action's contract is unchanged — it still returns the base64\nPDF — so callers see no difference. The persisted object surfaces in\nDrive's `/drive/System/Sales/Invoices/...` once Phase 10 lands.\n\nThe hook is fire-and-forget: a storage write failure does NOT break\nthe PDF download. The Storage module's `getAction()` guard keeps the\nsales runtime loadable even when `@helios/storage-module` isn't wired\n(unit tests, lightweight scripts).\n\n3 integration test cases (PGlite + local driver):\n- happy path: row + meter event + sharded counter all land\n- idempotent re-render: 2 rows (no row-level dedupe in v1) but only 1\n  meter event + 1 counter delta\n- module-not-loaded: returns null without throwing\n\nAlso fixes the meter's `recordStorageEvent` to use `.onConflictDoNothing()`\nwithout a target — drizzle's `target: column` form doesn't emit the\n`WHERE identifier IS NOT NULL` predicate the partial unique index\nrequires. Discovered via the integration test above.\n\nSee docs/plans/UNIFIED_STORAGE_AND_DRIVE/08_MODULE_INTEGRATION_GUIDE.md\n§sales.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.702Z","updatedAt":"2026-06-16T04:14:09.702Z"},{"id":"03b450e2-4522-4385-9ffc-b82105b56abb","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-profile-actions","type":"added","scope":"storage","summary":"Added the storage_profiles management actions — list, get, create, update, archive, assign.","body":"Phase 3A of the unified Storage + Drive plan. Six new registered\nactions on `@helios/storage-module/actions` give root operators\n(and tenant admins, for BYOB) the surface to provision additional\nprofiles and steer per-org routing without touching SQL:\n\n- `storage.profile.list` — optional includeArchived / byob /\n  ownerOrgId filters. Sorted by createdAt asc.\n- `storage.profile.get` — single profile by id. Sensitive config\n  keys (anything matching secret / password / token / credential /\n  access_key / api_key) are masked to `***` on read.\n- `storage.profile.create` — dual-policy: platform profiles require\n  `platform:storage:profile:manage`; BYOB profiles require\n  `storage:profile:byob:manage` and ownerOrgId = ctx.actor.orgId\n  (cross-tenant BYOB needs the platform perm).\n- `storage.profile.update` — sparse patch (only keys present are\n  written). byob and ownerOrgId are immutable. Editing bucket /\n  endpoint / region / config / encryption flips status back to\n  provisioning until verify confirms.\n- `storage.profile.archive` — soft-delete via status='archived'.\n  Refuses to archive the platform default (well-known UUID\n  `00000000-0000-0000-0000-00000000a001`).\n- `storage.profile.assign` — write per-org routing. Sparse merge\n  of perPurposeOverrides; null clears a slot; defaultProfileId\n  upserts. Emits `storage.profile.assigned`. Refuses to reference\n  archived profiles.\n\nNotes:\n- Profile-id Zod validation uses a lenient dashed-hex regex (not\n  the strict v4-with-variant `.uuid()`) so the legacy platform\n  default UUID seeded by the Phase 2A migration parses cleanly.\n  Org-id and user-id stay strict.\n- 18 schema-validation vitest cases + 15 PGlite integration tests:\n  create denial paths, masking, archive guard on platform default,\n  update reverts status to provisioning on connection change,\n  archived-profile-cannot-be-assigned, null-clears.\n\nThe admin UI (`/saas/storage` routes) lands in Phase 3B. The\nverify action (BYOB self-test) lands in Phase 3C.\n\nSee docs/plans/UNIFIED_STORAGE_AND_DRIVE/03_STORAGE_MODULE_SPEC.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:09.716Z","updatedAt":"2026-06-16T04:14:09.716Z"},{"id":"9bffeeb0-bd32-4138-988f-aa18bd0045b4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-server-put","type":"added","scope":"storage","summary":"Added storage.object.put for server-side direct uploads (rendered PDFs, archived MIME, etc.).","body":"Bridge between Phase 2E (the four core actions) and Phase 5 (the\nfirst consumer migration: sales invoice PDFs). `storage.object.put`\nis a single-phase server-only action — bytes are passed in as\n`Uint8Array`, so it can't be reached via the public JSON wire.\n\nThe shape mirrors `storage.object.put_url` + `storage.object.confirm_upload`\ncollapsed into one step: same quota gate, same profile resolve, same\nkey construction, same meter event. The provider write happens before\nthe row insert so a transfer failure never accounts for bytes that\ndon't exist; row-insert failure after a successful transfer leaves an\norphan that the Phase 8 sweep cleans up.\n\nFour schema-validation vitest cases cover the new input (valid\nminimal payload, rejects non-Uint8Array bytes, accepts Buffer since\nit extends Uint8Array, accepts optional sha256). The four prior\nactions are unchanged.\n\nSee docs/plans/UNIFIED_STORAGE_AND_DRIVE/08_MODULE_INTEGRATION_GUIDE.md\n§sales for how this fits the upcoming consumer migration.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:10.058Z","updatedAt":"2026-06-16T04:14:10.058Z"},{"id":"d053c168-e6c4-488e-86d4-21a552dddd5d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"verify-email-banner-honest-copy","type":"changed","scope":"web","summary":"Verify-email banner's resend toast now says \"queued\" instead of falsely claiming \"sent\".","body":"The verify-email banner's \"Resend verification\" button fired\n`toast.success(\"Verification email sent\")` unconditionally after the\nBetter-Auth client call returned. That client call returns success\nthe moment the outbound row is *queued* — actual delivery happens\nlater in the worker drain pass, which can fail (e.g. transient\nSMTP socket timeouts on a misconfigured platform provider) without\nthe user ever hearing about it. The user reported exactly this:\n\"says sent but not received\" across multiple retries.\n\nTwo changes:\n\n1. **Honest copy**: title → \"Verification email queued\", body adds\n   \"If it doesn't arrive, contact your admin\" so the user knows the\n   queue + delivery are distinct steps and where to escalate.\n\n2. **Faster banner reaction**: after a successful queue, invalidate\n   the `platform.onboarding.feed.list` query 20s later so a\n   verify-and-click round-trip flips the banner off without waiting\n   for the 5-minute refetchInterval.\n\nThe underlying delivery failure (admin needs to configure a platform-\ntier provider that doesn't hit blocked SMTP ports) is still an infra\nissue, but the banner no longer pretends the problem is solved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:14:10.089Z","updatedAt":"2026-06-16T04:14:10.089Z"},{"id":"1767060a-392a-42ed-9e86-207fd2f89ced","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"onboarding-strictness-phase-f","type":"changed","scope":"web","summary":"Onboarding strictness Phase F — warning-level steps suppressed from dashboard widget + excluded from progress percentage.","body":"Closes the strictness spec's consumer-polish phase. Three changes:\n\n1. **Feed action** — `progress.{completed,skipped,pending,total,\n   percent}` now compute over the items that pass `!hideFromBanners`.\n   A `warning`-level step is advisory: it appears in the items array\n   so the security score can surface it in its breakdown, but it\n   doesn't peg the percentage at \"X of Y done\" forever for everyone.\n\n2. **Dashboard widget** (`SecurityOnboardingWidget`) filters items\n   with `hideFromBanners === true` before mapping rows. Without\n   this, a \"Set timezone\" warning entry would have shown up under\n   \"Finish setting up\" indefinitely for users who never set their\n   tz.\n\n3. **Type extension** — both consumer-side `FeedItem` typedefs\n   gained the optional `hideFromBanners` field. Optional for\n   back-compat with cached responses pre-Phase-B.\n\nNew platform test pins the contract: `warning` items appear in\n`items`, carry `hideFromBanners: true`, but don't count toward\n`progress.total`. `off` items stay dropped entirely. Strict +\nrelaxed items remain the sole drivers of the percentage.\n\n123 platform + 411 saas + 115 iam tests pass.\n\nWhat's still queued (intentional follow-up):\n- Audit log entries on platform.onboarding.update + the org\n  strictness set action (the existing audit infrastructure logs\n  every action invocation; explicit \"strictness changed\" events\n  would be richer).\n- Surfacing the catalog defaults via the action read so the\n  Phase-D/E mirrors can be deleted.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T04:15:08.968Z","updatedAt":"2026-06-16T04:15:08.968Z"},{"id":"d110d752-5473-460b-a3dd-cfa008d3c88f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"payroll-payslip-cache-sanitization","type":"fixed","scope":"payroll","summary":"Payroll payslip storage-cache helper now sanitizes payslipNumber for the metadata jsonb (NUL bytes would silently abort).","body":"Defense-in-depth follow-up to the Phase 6 payroll bridge (commit\n520ac08b). The cache helper now strips path separators / NUL /\nC0 control chars / leading dots from `payslipNumber` and reuses\nthe sanitized value for BOTH the `storage_objects.filename`\ncolumn AND the `metadata.payslipNumber` field.\n\nToday's only caller derives `payslipNumber` from a controlled\nper-run counter (always safe), but the helper is exported with\na `payslipNumber: string` contract. A future caller wiring it\nfrom an operator-renumber UI, external imports, or any other\nuser-influenced source would otherwise hit either:\n\n1. `storage_objects.filename` carrying a path separator / NUL,\n   breaking the Drive item label and the proxy download URL, OR\n2. `metadata.payslipNumber` carrying a NUL byte, which Postgres\n   jsonb columns REJECT with \"unsupported Unicode escape\" — that\n   would turn the entire bridge into a silent no-op for the\n   hostile payslip while the legacy upload kept working.\n\nA `sanitizes a hostile payslipNumber` integration test pins the\nbehavior. Same defense was applied to the payments receipt\nbridge in the same review pass (commit fa8c391e).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:03.490Z","updatedAt":"2026-06-16T13:46:03.490Z"},{"id":"93c1cd68-8e62-4938-809a-5ccbb5abc74e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"payments-receipt-email-via-storage-object-id","type":"fixed","scope":"payments","summary":"Payment receipt emails actually attach the PDF now (drain was silently dropping legacy storageKey entries).","body":"Closes the loop opened by the Phase 6 payments-storage bridge\n(commit `fa8c391e`) and the email module's three-shape attachment\ncontract (commits `88bcb82d` + `a4654ae1`).\n\n**Before:** the `payments.charge.succeeded` subscriber attached\nthe receipt PDF as `{ storageKey, filename, contentType }`. The\nemail worker's drain treats `storageKey`-only entries as a\nlegacy back-compat path and SKIPS them — so receipt emails went\nout with NO attachment for every successful charge. Confirmed in\n`modules/email/src/schemas/send.ts:97-101` (the schema docstring\ncalls out the skip) and in the discover-phase audit of the email\nmodule.\n\n**After:** the subscriber captures the object id returned by\n`cacheReceiptPdf` (the Phase 6 bridge that lands a\n`storage_objects` row alongside the legacy upload) and passes\n`storageObjectId` to `email.outbound.send`. The drain materialises\nthe bytes from storage at SMTP-build time.\n\nThree-shape preference order matches the email module's contract:\n\n  1. `storageObjectId` — the canonical path when the cache write\n     succeeded.\n  2. `contentBase64` — fallback for the two no-objectId cases:\n     (a) PLATFORM_ORG_ID charges (signup / marketing-site plan\n     purchases) where the Phase 6 bridge intentionally skips to\n     avoid billing the platform tenant for non-customers, and\n     (b) cache failures. The email module's send action persists\n     the bytes through `storage.object.put` internally — same\n     downstream shape.\n  3. `storageKey` — never used. Drain skips it.\n\nNet effect: every successful charge now produces a receipt email\nWITH the receipt PDF attached. Signup / marketing buyers\n(platform-org) get the receipt inline via `contentBase64`;\ntenant-org charges get the canonical `storageObjectId` path.\n\nNo schema migration — leverages the join-table + dual-write\ninfrastructure the email module already shipped.\n\nThe matching follow-up for `recruitment.offer.sent` (which has\nthe same `storageKey`-only bug today) is queued separately —\nthat path needs either a `recruitment_offers.pdf_storage_object_id`\ncolumn or a query-time lookup against `storage_objects.metadata`,\nbecause the email subscriber doesn't have the cache's objectId\nin scope.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:28.748Z","updatedAt":"2026-06-16T16:34:28.748Z"},{"id":"6ad4b912-9f72-4fa9-89cf-aa0e73173a94","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-get-stream","type":"added","scope":"storage","summary":"New storage.object.get_stream action — server-side direct bytes fetch for producer modules that need to materialise an object (email outbound drain, raw-MIME parser).","body":"Phase 1 commit 3 of the email + mailbox → unified storage migration.\nLands the bytes-fetch action the email-outbound drain needs to swap\nits base64-inline path for storage-referenced attachments.\n\n## What changed\n\n- **New driver method `StorageClient.getObject({key})`** returning\n  `{ bytes, contentType, sizeBytes, etag, lastModifiedAt } | null`.\n  Implemented in the S3 driver (via `GetObjectCommand` +\n  `transformToByteArray`), the local filesystem driver (via\n  `node:fs/promises.readFile`), and the in-memory fake. v1 returns a\n  buffered `Uint8Array` — fine for the 25 MiB SMTP cap the email\n  drain enforces. A future revision adds a sibling\n  `getObjectStream` for the Drive-tier large-object path.\n\n- **New action `storage.object.get_stream`** — server-only direct\n  bytes fetch. Output isn't JSON-serialisable on purpose (same\n  pattern as `storage.object.put` input); the action is reachable\n  only via the Node action registry. Public HTTP downloads continue\n  to use `storage.object.get_url` → presigned URL → browser.\n\n  Gates mirror `get_url`:\n  - row missing OR soft-deleted → `not_found`\n  - cross-tenant read without `platform:storage:usage:read` →\n    `policy_denied`\n  - `scan_state !== 'clean'` → `policy_denied` (infected /\n    dlp_blocked / pending / scan_error)\n  - row exists but provider object is gone (orphan-row case) →\n    `dependency_failed` (caller retries; the reconciliation cron\n    eventually notices the divergence)\n\n  Writes a `storage.object.egressed` meter event before returning.\n\n## Why\n\nThe email-outbound drain at `modules/email/src/jobs/drain-outbound.ts`\nsilently skips `storageKey`-only attachment entries — a known debt\ndocumented in the code. The blocker has been \"no server-side bytes\nfetch action exists.\" This commit closes that gap. The drain's swap\nto fetching attachment bytes via `getStream` lands in a follow-up\ncommit (Phase 1 commit 6).\n\n## Tests\n\n- `modules/storage/src/actions/object-get-stream.integration.test.ts`\n  (8 PGlite cases): bytes round-trip, egress meter, not_found on\n  missing row, not_found on soft-deleted row, policy_denied on\n  scan_state != clean, cross-tenant denial, cross-tenant allow with\n  platform perm, dependency_failed when provider object is missing.\n\n53/53 storage integration tests pass (45 prior + 8 new); 80/80 unit;\n299/299 mailbox; 270/270 email; 3/3 sales pdf-storage-cache.\n\n## Reference\n\n- Action: [modules/storage/src/actions/object.ts](../../modules/storage/src/actions/object.ts) (`getStream`)\n- Schema: [modules/storage/src/schemas/object.ts](../../modules/storage/src/schemas/object.ts) (`GetStreamInput` / `GetStreamOutput`)\n- Driver: [packages/storage/src/index.ts](../../packages/storage/src/index.ts) (`StorageClient.getObject`)\n- D-9 — alternative considered (presigned URL + fetch) deferred to\n  external-recipient UX; see\n  [docs/plans/UNIFIED_STORAGE_AND_DRIVE/10_OPEN_DECISIONS.md](../../docs/plans/UNIFIED_STORAGE_AND_DRIVE/10_OPEN_DECISIONS.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:28.993Z","updatedAt":"2026-06-16T16:34:28.993Z"},{"id":"3cfaebf5-fe5b-4931-b14f-f292539ab987","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-profile-verify-cron","type":"added","scope":"worker","summary":"Daily storage-profile verify cron — re-roundtrips every non-archived profile every 7 days.","body":"Phase 3D-2 of the unified Storage + Drive plan. Catches the\n\"credentials expired six weeks ago and nobody noticed\" failure\nmode that BYOB on-call typically files as a P1.\n\n`runProfileVerifySweep(db, ...)` in `@helios/storage-module/jobs`\nwalks every non-archived `storage_profiles` row whose\n`last_verified_at` is older than 7 days (or NULL) and invokes\n`storage.profile.verify` on each — full PUT → HEAD → fetch →\nDELETE → LIST roundtrip per Phase 3D-1. Returns `{ scanned,\nattempted, verified, failed, errors, newlyFailed }` so the\noperator dashboard can render outcomes.\n\nWorker cron at `apps/worker/src/storage-profile-verify-cron.ts`\nruns the sweep daily (24h interval, 5min initial delay, max\n100 profiles per sweep). Writes a heartbeat record on every\ntick; flags errors when any profile flipped from verified →\nverify_failed during the sweep (the `newlyFailed` array).\n\nDefaults match the spec:\n  staleAfterMs   = 7 days\n  maxProfiles    = 100\n  intervalMs     = 24 hours\n  initialDelayMs = 5 minutes (avoids cron storms on fresh boot)\n\nWorker now depends on `@helios/storage-module` for the sweep\nhelper + a side-effect import of `@helios/storage-module/actions`\nso the cron's `invoke(verifyProfile)` finds the registered action.\n\n3 PGlite integration tests (40 storage integration tests now):\n  - verifies stale + never-verified profiles, skips fresh ones\n  - skips when no profiles meet the staleness threshold\n  - respects maxProfiles cap\n\nThe newly-failed notification fan-out (an email subscriber on\n`storage.profile.verified` with `status='verify_failed'`) lands\nin 3D-3 with the BYOB credential KMS-wrap work.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:29.204Z","updatedAt":"2026-06-16T16:34:29.204Z"},{"id":"b6d27e16-28dc-4d6f-9fa1-61df8700402f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"strictness-select-primitives","type":"changed","scope":"web","summary":"Strictness dropdowns now use `@helios/ui` Select primitive instead of raw `<select>`.","body":"The strictness picker rows in `/saas/onboarding` and\n`/settings/organization` were rendered with raw `<select>` elements\n+ hand-rolled Tailwind chrome — violating the forms rule (no native\ninput/select/textarea in app code; use the polished `@helios/ui`\nprimitives instead).\n\nBoth surfaces now use `Select` from `@helios/ui` — a native drop-in\nthat spreads native props + keeps `<option>` children, so the\nswap is mechanical. The `size=\"sm\"` size + `w-[120px]` width stay\nvisually close to the previous chrome.\n\nR3 #4 from the adversarial review.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T16:34:29.217Z","updatedAt":"2026-06-16T16:34:29.217Z"},{"id":"b182a71b-4ae5-488a-9dae-5eb7184dc02e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-drain-storage-read","type":"changed","scope":"email","summary":"Outbound drain reads attachments from the storage join table via storage.object.get_stream, with AV-aware retry-and-fail-permanent on scan-blocked attachments.","body":"Phase 1 commit 9 of the email + mailbox → unified storage migration.\nThe producing side (Phase 1 commit 8) landed in\n`a4654ae1`; this commit flips the consuming side so the bytes that\nthe send-action stashed in storage actually reach the recipient.\n\n## What changed\n\n`modules/email/src/jobs/drain-outbound.ts`:\n\n- New `buildAttachmentsForRow()` helper that reads\n  `email_outbound_attachments` join rows FIRST. Each\n  `storage_object_id` is materialised via the action-registry's\n  `storage.object.get_stream` (server-only, returns raw\n  `Uint8Array` + content-type + filename + sha256). Bytes are\n  base64-encoded into the existing `Attachment.content` shape\n  so adapters (SMTP, Postmark, Resend, SES, …) consume them\n  identically to the legacy `contentBase64` path.\n\n- Dual-read fallback: a row with NO join entries (legacy\n  outbound queued pre-Phase-1-commit-8 OR a `storageKey`-only\n  attachment) falls through to the existing `buildAttachments`\n  jsonb reader. The window survives until the Phase 1 commit 11\n  backfill catches up.\n\n- New `AttachmentBlockedError` class. Thrown when\n  `storage.object.get_stream` denies an attachment because\n  `scan_state != 'clean'` (AV / DLP gate). Caught in\n  `processOne` and routed to retry-or-fail-permanent.\n\n## AV-gate retry semantics\n\n- **Retry**: if `attempt_count < MAX_ATTEMPTS` (6), the row goes\n  to `status='retrying'` with `last_error='attachment_blocked:\n  <reason>'` and `next_attempt_at` on the existing\n  `BACKOFF_MINUTES` schedule (30s → 2m → 10m → 30m → 2h → 6h).\n  Rationale: a `pending` scan typically flips to `clean` within\n  seconds; even an `infected` flag can flip to `clean` after the\n  signature db is bumped.\n\n- **Permanent**: after MAX_ATTEMPTS the row goes to\n  `status='failed'` with the same `attachment_blocked:` reason.\n  Support tooling can grep that prefix to explain WHY the email\n  didn't ship. The existing `notify-on-failed-exhausted` job\n  pages the recipient's org admin like any other permanent\n  failure.\n\nAny other materialisation error (storage action not registered,\nmissing object, cross-tenant denial, driver outage) is a hard\nfailure with reason `attachment_load_failed:<msg>` — those don't\nself-heal so retrying just chews through attempts without value.\n\n## Why bytes-as-base64 and not streaming-through-SMTP\n\nThe existing `Attachment` type ships either a base64 string or\n`Uint8Array`. Existing provider adapters (SMTP, Postmark,\nResend, SES, Mailgun, SendGrid) all consume the in-memory\ncontent directly. Bytes-as-base64 preserves that contract;\nstreaming would have meant rewriting every adapter. Memory\nceiling per drain worker stays bounded by `Attachment` size cap\n+ the existing 10-attachments-per-message limit.\n\n## Boundary respected\n\nThe drain calls `getAction('storage.object.get_stream')` and\n`invoke()` — never a direct `@helios/storage-module` import.\nConsistent with the rest of the email module's cross-module\ncontract.\n\n## Tests\n\n`modules/storage/src/lib/email-drain-storage-read.integration.test.ts`\n(4 PGlite cases through the real `drainOutboundOnce`):\n\n- storageObjectId attachment → bytes materialise via\n  get_stream → provider receives base64-encoded content\n- legacy contentBase64 (no join rows) → fallback path still works\n- scan_state='infected' → AttachmentBlockedError → row to\n  retrying with `attachment_blocked: ... infected` last_error\n- exhausted attempts under AV block → row marked failed with\n  the same reason\n\n76 storage integration tests (68 prior + 4 new + 4 from other\nparallel-session work) + 270 email module unit tests pass.\n\n## Operator impact\n\n- Drain workers must boot with `@helios/storage-module/actions`\n  registered so `getAction('storage.object.get_stream')`\n  resolves. Phase 1 commit 10 wires this at apps/worker boot.\n- A previously-stuck attachment (scan_state='pending' for hours)\n  now self-clears as soon as the scanner job marks the object\n  clean — no manual intervention.\n- Old `storageKey`-only attachments (legacy path that was\n  silently skipped) STILL get skipped — the backfill in Phase 1\n  commit 11 converts them to `storage_object_id` so future\n  drains pick them up.\n\n## Reference\n\n- Drain: [modules/email/src/jobs/drain-outbound.ts](../../modules/email/src/jobs/drain-outbound.ts) (`buildAttachmentsForRow`, `AttachmentBlockedError`)\n- Stream action: [modules/storage/src/actions/object.ts](../../modules/storage/src/actions/object.ts) (`getStream`)\n- Producing side: Phase 1 commit 8 (`a4654ae1`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.220Z","updatedAt":"2026-06-16T17:53:13.220Z"},{"id":"c2dfca69-4253-40c8-a27f-fa3c790e62e2","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-storage-read-access-verifier","type":"added","scope":"email","summary":"Email module registers a storage read-access verifier at boot — owner-module access decisions for outbound attachments now require email:outbound:read or a system actor.","body":"Phase 1 commit 10 of the email + mailbox → unified storage migration.\n\nThe storage module's access registry (Phase 1 commit 5 / `89b6742e`)\nasks the producing module to decide who can read its\n`storage_objects`. Before this commit no producer had registered a\nverifier, so `storage.object.get_url` / `get_stream` applied the\n\"unregistered owner_module → allow + warn\" fallback to every\nemail-owned attachment. That meant any user in the org could pull\nthe bytes — too permissive for an audit surface.\n\n## What changed\n\nNew module `modules/email/src/lib/storage-read-access.ts`:\n\n- `emailStorageReadAccessVerifier({actor})` — the pure decision\n  function. Allow if `actor.type === 'system' || 'service'`\n  (drain, backfill, retention reaper) OR the actor holds\n  `email:outbound:read` OR `email:inbound:read`. Otherwise deny.\n\n- `registerEmailStorageReadAccess()` — boot-time side-effect\n  registration. Called from `modules/email/src/actions/index.ts`\n  so any consumer that imports `@helios/email-module/actions`\n  (the web app + the worker both do) picks it up automatically.\n\n## Why these rules\n\nStorage's gates already cover org isolation + AV scan-state +\nthe Q15 personal-scope fence. The email-module verifier owns\nthe user-level decision. The rules above answer \"WHO inside\nthe right org can audit-read an email's bytes\":\n\n- **System / service**: the drain itself fetches via a system\n  context (`createSystemContext({permissions:['storage:object:read:own']})`)\n  to build the SMTP multipart; the upcoming backfill cron walks\n  legacy `attachments_json` under a system context too. Neither\n  has (or should have) a tenant-user identity.\n- **email:outbound:read / email:inbound:read**: held by\n  `email_admin` + admin role blueprints. Backs audit surfaces\n  (\"show what shipped in this outbound row\"), the resend flow,\n  admin debug.\n- **Everything else**: deny. Tenant users seeing their OWN\n  sent emails go through the mailbox-client UI, not through\n  the storage object surface. A recipient who got the email\n  reads it from THEIR mailbox provider — we don't grant\n  cross-recipient retrieval against the sender-org's bytes.\n\n## Tests\n\n`modules/email/src/lib/storage-read-access.test.ts` (8 unit cases):\n\n- system / service actors → allow\n- holders of email:outbound:read → allow\n- holders of email:inbound:read → allow (forward-compat for\n  Phase 2 inbound migration)\n- plain tenant user without the permission → deny\n- AI agent without the permission → deny\n- AI agent WITH the permission → allow\n- platform:storage:usage:read alone does NOT short-circuit\n  (audit clarity)\n\nPlus the existing 4 drain integration tests in storage-module's\nsuite continue to pass — the drain uses a system context so it\ngets through the new verifier cleanly.\n\n278 email module + 76 storage integration tests pass.\n\n## Boundary footnote\n\nThis commit adds `@helios/storage-module` to\n`@helios/email-module`'s regular dependencies. The reverse\n(storage-module → email-module) is dev-only — added in Phase\n1 commit 8 for the integration test that invokes the real send\naction. Asymmetric workspace deps are fine; pnpm doesn't\nreject them.\n\n## Reference\n\n- Verifier: [modules/email/src/lib/storage-read-access.ts](../../modules/email/src/lib/storage-read-access.ts)\n- Registry: [modules/storage/src/lib/access-registry.ts](../../modules/storage/src/lib/access-registry.ts) (Phase 1 commit 5)\n- Wire site: [modules/email/src/actions/index.ts](../../modules/email/src/actions/index.ts) (`registerEmailStorageReadAccess()`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.226Z","updatedAt":"2026-06-16T17:53:13.226Z"},{"id":"2bfca4ab-65d6-4205-b1f5-7c817e7ddd30","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"i18n-phase-f-consolidate","type":"added","scope":"i18n","summary":"i18n Phase F — `platform.i18n.consolidate_to_platform` migration tool for existing deployments.","body":"Sixth and final slice of the i18n platform-tier rollout. Adds the\none-shot migration tool that root operators run on existing\ndeployments where every tenant carries duplicate-of-platform i18n\nrows seeded at `saas.organization.create` time before Phase E\ndropped that block.\n\n`platform.i18n.consolidate_to_platform` walks every tenant org's\n`i18n_locales`, `i18n_keys`, and `i18n_translations` rows and:\n\n1. Loads the platform-tier catalogue into in-memory maps.\n2. For each tenant row, checks whether its value matches the\n   platform-tier row's value.\n3. **Deletes** the row when it matches (tenant inherits from\n   platform after).\n4. **Preserves** the row when it differs — genuine override that\n   tenants want to keep.\n\nDefaults to `dryRun: true` so the first call surfaces \"this would\ndelete N rows\" to the operator. Pass `dryRun: false` to apply.\nIdempotent — re-runs find nothing to dedup.\n\nThe action carries `dangerous: true` (deletes rows). Root only.\nReturns `{ orgsScanned, localesConsolidated, keysConsolidated,\ntranslationsConsolidated, overridesPreserved: { locales, keys,\ntranslations } }` so the operator gets a precise audit number.\n\n### Recommended invocation flow\n\nFor existing deployments after the Phases A–E commits land:\n\n1. Root operator hits `/saas/i18n/languages` → \"Seed starter\n   locales\" if the platform catalogue is empty.\n2. Run `pnpm seed:i18n --platform-tier` (when shipped) to fill\n   the 6,929 platform-tier keys + English translations.\n3. Call `platform.i18n.consolidate_to_platform { dryRun: true }`\n   → review the preview.\n4. Call `platform.i18n.consolidate_to_platform { dryRun: false }`\n   → apply.\n5. Per-org `i18n_*` tables now hold ONLY tenant-specific\n   overrides; platform updates flow through automatically.\n\n### Rollout complete\n\nAll six phases of the i18n platform-tier spec\n([docs/plans/I18N_PLATFORM_TIER_SPEC.md](../../docs/plans/I18N_PLATFORM_TIER_SPEC.md))\nhave shipped:\n\n- **A** — schema with nullable `org_id` + partial unique indexes\n- **B** — tenant reads inherit platform rows\n- **C** — `platform.i18n.*` action surface (root-only writes)\n- **D** — `/saas/i18n/languages` admin UI\n- **E** — drop per-org locale seed in `saas.organization.create`\n- **F** — consolidation migration tool (this commit)\n\nNew orgs land with zero per-org i18n rows and see the full\nEnglish catalogue immediately via inheritance. Tenants can still\noverride specific values per-org through the existing\n`/settings/languages` + `/settings/translations` surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.501Z","updatedAt":"2026-06-16T17:53:13.501Z"},{"id":"d5a028a5-901d-476e-8732-ee1678649cc9","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"i18n-tenant-ui-inheritance-aware","type":"changed","scope":"web","summary":"/settings/languages hides Remove on inherited locales + shows a \"Platform\" badge so tenants understand the override surface.","body":"Post-rollout polish on the tenant-side `/settings/languages` page.\nPhase B of the i18n platform-tier rollout exposed `isPlatform:\nboolean` on every `LocaleRow` returned by `i18n.locale.list`; the\ntenant page hadn't used it yet.\n\nTwo changes:\n\n1. **Hide the Remove menu item on inherited rows** — the row's\n   `org_id IS NULL` so `i18n.locale.delete` returns `not_found`\n   (it filters by `ctx.orgId`). Without the gate, tenants saw a\n   Remove button that yielded \"Locale not found\" — confusing UX.\n   Now Remove only appears for rows the tenant actually owns\n   (per-org overrides). Platform locales are managed by Helios\n   operators via `/saas/i18n/languages`.\n\n2. **\"Platform\" badge** next to inherited locales in the catalogue\n   table. Surfaces inheritance explicitly so tenants know which\n   rows are \"inherited from Helios\" vs \"we customised this one.\"\n\nDisabling a platform locale via the toggle still works — that\ninserts a per-org override row that disables the locale for this\ntenant only. Tenants who want to hide a locale do so by disabling\nit; tenants who want to OVERRIDE the metadata create a per-org\ncopy through the existing upsert path.\n\nTest posture unchanged — same actions, same surface, just\ninheritance-aware rendering.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.505Z","updatedAt":"2026-06-16T17:53:13.505Z"},{"id":"54c55b09-1cf4-438f-8e00-c86100ece718","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-routes-tree-regen","type":"fixed","scope":"web","summary":"/saas/mailbox, /saas/storage, /saas/storage/routing, and /saas/roadmap/$featureId stop 404'ing — route ids were cast to `as never` which broke the generator silently.","body":"A user reported staging `/saas/mailbox` returning the 404 page. The\nroute file was on disk and the action layer was healthy. Root cause\nwas upstream of both: the route was never registered in\n`apps/web/src/routeTree.gen.ts` because the TanStack Router\ngenerator silently bailed out.\n\n## The Catch-22\n\nThree recently-added route files shipped with `createFileRoute(\n'/path' as never)`. The `as never` cast was a TypeScript-error-\nsilencing hack — added because adding a new route file produces a\nchicken-and-egg type error (\"path not assignable to FileRoutesByPath\")\nuntil the next dev/build pass regenerates the tree.\n\nBut the TanStack Router CLI generator REFUSES to parse a route id\nthat isn't a string-literal or plain template-literal. The\n`as never` cast turned the literal into a non-literal expression,\nthe generator threw `expected route id to be a string literal`,\nand — critically — **the generator bails out on the first failure,\nabandoning every other new route file in the same pass.**\n\nThat's why the user-reported 404 cascade hit even files like\n`/saas/mailbox` whose own route id was clean: the broken\n`storage.tsx`+`storage.routing.tsx`+`roadmap.$featureId.tsx`\npoisoned the run for everything queued after them.\n\n## The fix\n\nRemoved the `as never` casts from the three affected files:\n\n- `apps/web/src/routes/saas/storage.tsx`\n- `apps/web/src/routes/saas/storage.routing.tsx`\n- `apps/web/src/routes/saas/roadmap.$featureId.tsx`\n\nRe-ran the generator. `routeTree.gen.ts` now includes all five\npreviously-missing routes:\n\n- `/saas/mailbox` (root-only platform mailbox config — Phase 0 of\n  the IMAP/SMTP slice at `df789fd7`)\n- `/saas/storage` (storage profile management)\n- `/saas/storage/routing` (per-org storage routing)\n- `/saas/roadmap` (already existed; reattached as a layout parent)\n- `/saas/roadmap/$featureId` (admin feature detail)\n\n## New helper to prevent recurrence\n\n`apps/web/scripts/regen-route-tree.mjs` drives the same\n`@tanstack/router-generator` the Vite plugin drives, but as a\none-shot Node script. Run it after adding a new route file (and\nbefore committing) to avoid the catch-22 entirely:\n\n```\nnode apps/web/scripts/regen-route-tree.mjs\n```\n\nThe script reports each broken-route-id error explicitly so the\n\"why is my route 404'ing\" question has a fast answer.\n\n## Going forward\n\nWhen you add a new route file:\n\n1. Use a plain string literal in `createFileRoute('/foo/bar')`.\n   Do NOT add `as never` — the typecheck error you'd be silencing\n   is the signal that the generator hasn't run yet.\n2. Run `node apps/web/scripts/regen-route-tree.mjs` to refresh\n   the route tree.\n3. Commit BOTH the new route file AND the regenerated\n   `routeTree.gen.ts`. Pre-commit lint + CI would otherwise\n   miss the divergence — the tree is auto-regenerated only on\n   `pnpm dev` / `pnpm build`, and some CI pipelines ship from a\n   cached artifact that trusts the checked-in file.\n\n## Reference\n\n- Helper script: [apps/web/scripts/regen-route-tree.mjs](../../apps/web/scripts/regen-route-tree.mjs)\n- Generated tree: [apps/web/src/routeTree.gen.ts](../../apps/web/src/routeTree.gen.ts)\n- TanStack generator source: `node_modules/@tanstack/router-generator/dist/esm/generator.js`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.771Z","updatedAt":"2026-06-16T17:53:13.771Z"},{"id":"9da03754-8b8a-4cf5-a469-e62d9f111422","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-confirm-upload-head-verify","type":"security","scope":"storage","summary":"confirm_upload now HEAD-verifies provider bytes against the declared size before writing the meter.","body":"Phase G.1.1 of the unified Storage + Drive plan. Closes the\nunder-declare meter gap flagged by the D-23 design review\n(see [`11_REGISTER_DECISION.md`](../../docs/plans/UNIFIED_STORAGE_AND_DRIVE/11_REGISTER_DECISION.md)\n§\"What confirm_upload still needs\").\n\n**Before:** `confirm_upload` only validated the optional\ncaller-supplied `actualSizeBytes` against the declared size.\nThe declared size from `put_url` was authoritative for the\nmeter. A malicious browser could presign for 1 KB (quota\nallows it), PUT 5 GB through the presigned URL (most\nproviders enforce Content-Length on signed-header mode but\nbehavior varies across providers / multipart), and confirm\nwith `actualSizeBytes=1024` — the meter recorded 1 KB and\nthe org silently exceeded cap by 5 GB.\n\n**After:** the handler calls `client.headObject({ key:\nrow.storageKey })` BEFORE writing the meter event and uses\nthe provider-reported size as authoritative. Three new\nbranches:\n\n| HEAD result | Verdict | Side effect |\n|---|---|---|\n| `null` (PUT not yet at provider) | `validation_failed` | Row stays `scanState='uploading'` so caller can retry after the PUT completes |\n| `head.size !== row.sizeBytes` | `validation_failed` | Row flipped to `scanState='scan_error'` so `get_url` refuses to presign it; bytes at provider become orphans reaped by the Phase R orphan-sweep cron; **no meter event** |\n| throws (network/driver error) | `dependency_failed` | Row stays `uploading` (fail-CLOSED — we never mark clean on an unverified row); caller retries |\n| `head.size === row.sizeBytes` | happy path | Meter event + scanState='clean' as before |\n\nThe optional caller-supplied `actualSizeBytes` check stays as\na cheap pre-flight (fail fast without a driver round-trip) but\nthe HEAD result is now the final word.\n\n7 PGlite integration tests at\n`modules/storage/src/actions/object-confirm-head.integration.test.ts`\ncover every new branch including the over-declare direction\n(corrupted partial uploads), idempotent re-call (already-clean\nrow short-circuits before HEAD), and the caller-supplied\npre-flight mismatch.\n\nThis was the prerequisite hardening before any Phase 6 lane 2\nbrowser-presign consumer migration lands — every consumer's\nPUT path now inherits the under-declare protection without\nthe consuming module having to opt in.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.794Z","updatedAt":"2026-06-16T17:53:13.794Z"},{"id":"c6dd2dec-3cac-4814-b6c4-8291773d18a6","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"i18n-seed-script-platform-tier","type":"changed","scope":"i18n","summary":"`pnpm seed:i18n` now writes the source-code catalogue to platform tier; `platform.i18n.key.sync` bulk action.","body":"The closing piece of the i18n platform-tier rollout. Phases A–F\nshipped the architecture; this commit fills the catalogue so the\nuser's original ask — \"auto seed english language translations and\nkey for all new organization\" — actually resolves end-to-end.\n\nTwo changes:\n\n1. **`platform.i18n.key.sync`** — new root-only bulk action that\n   mirrors the per-tenant `i18n.key.sync` but writes to\n   `org_id IS NULL`. Idempotent: inserts new keys, updates\n   source/description when changed, leaves untouched anything not\n   in the batch. Used by the rewritten `seed-i18n` script + any\n   future codegen pipeline that wants to push platform keys.\n\n2. **`pnpm seed:i18n`** rewritten — no longer loops every tenant\n   org seeding per-org rows. Now picks any root user for the audit\n   FK columns and writes to platform tier in two action calls:\n   - `platform.i18n.catalog.seed` → 7 starter locales.\n   - `platform.i18n.key.sync` → the ~6,929 source-code keys\n     chunked at 1000/call.\n\n   Each invocation is ~1 second; the whole script runs in under\n   30 seconds for a fresh deploy.\n\nAfter the script runs, every tenant — existing AND newly created —\nsees the full English catalogue via inheritance with ZERO per-org\nrows written. The `LEGACY` per-tenant path is intentionally dropped\n(`org_id` is no longer the seed target); operators on pre-rollout\ndeployments use `platform.i18n.consolidate_to_platform` to dedupe\ntheir existing per-org duplicates against the new platform-tier\nrows.\n\nTest posture: 21 i18n tests now (+3 covering policy denial,\nvalidation rejection, and the insert path).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.506Z","updatedAt":"2026-06-16T17:53:13.506Z"},{"id":"c26f8eb3-1365-421b-8971-4495e1e0d2b6","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"i18n-phase-c-platform-actions","type":"added","scope":"i18n","summary":"i18n Phase C — `platform.i18n.*` write surface for root-managed catalog.","body":"Third slice of the i18n platform-tier rollout. Phase B made tenant\nreads inherit; Phase C adds the write surface for the platform-tier\ncatalog so root operators have an action layer to manage it.\n\nSeven new actions (all root-gated by `platform:root`):\n\n- `platform.i18n.locale.{list,upsert,delete}` — manage the\n  platform-tier locale catalog. Upsert is idempotent on `code`;\n  `isDefault: true` demotes other platform locales atomically.\n  Delete is soft (sets `deleted_at`).\n- `platform.i18n.key.{list,upsert}` — manage the platform-tier\n  key catalog. List is cursor-paginated by fullKey. Upsert is\n  idempotent on `(namespace, key)`.\n- `platform.i18n.translation.upsert` — write a translation for\n  a platform-tier key. Rejects with `validation_failed` if the\n  target keyId resolves to a tenant-owned row (cross-tier\n  integrity guard: platform-tier translations always reference\n  platform-tier keys).\n- `platform.i18n.catalog.seed` — idempotent seed of the 7 starter\n  locales (en, ar, fr, es, de, zh-cn, ja) at the platform tier.\n  Replaces the per-org locale seed that lived in\n  `saas.organization.create` (Phase E will delete that block).\n\nAll writes target `org_id IS NULL` rows specifically. Tenant\ninheritance reads (Phase B) pick them up automatically — a fresh\norg instantly sees every platform-tier locale + key + translation.\n\n7 new contract tests pin: root-only policy on all 7 actions,\ninput validation, the cross-tier guard on translation writes,\nand not-found for missing keys. 18 i18n tests now (+7).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.524Z","updatedAt":"2026-06-16T17:53:13.524Z"},{"id":"9dd8b23d-4a6f-48d4-987f-4a6af7441f2d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"i18n-phase-b-resolver","type":"changed","scope":"i18n","summary":"i18n Phase B — tenant reads inherit platform-tier rows (locale list, translation list, bundle fetch).","body":"Second slice of the i18n platform-tier rollout\n([docs/plans/I18N_PLATFORM_TIER_SPEC.md](../../docs/plans/I18N_PLATFORM_TIER_SPEC.md)).\nNow that the schema allows `org_id IS NULL` rows (Phase A), tenant\nreads consume both tiers — platform-tier rows fall through as the\nfallback, per-org rows override them on collision.\n\nThree read paths updated:\n\n- `i18n.locale.list` — reads platform + tenant locales, dedupes by\n  `code` with the tenant row winning. Each `LocaleRow` now carries\n  `isPlatform: boolean` so the UI can hide the delete button on\n  inheritance-only rows (D-3 in the spec).\n- `i18n.translation.list` — reads platform + tenant keys and\n  translations. Dedupes by `fullKey = namespace.key`, preferring\n  rows where either the key or the translation is tenant-owned.\n  Returns one row per fullKey regardless of which tier(s) carry it.\n- `i18n.bundle.fetch` — the runtime hot path (every page boot).\n  Locales, keys, and translations all consume the inherited filter.\n  Bundle-fill order flipped via `orderBy(desc(orgId))` so tenant\n  rows write first and the platform fallback only fills empty\n  slots. Effect: tenant override beats platform per (key, locale).\n\nNew shared helpers in `modules/i18n/src/lib/inheritance.ts`:\n\n- `inheritedOrgFilter(orgId, column)` — the `OR` clause every\n  inherited read uses.\n- `dedupeByKey(rows, getKey, comparator)` — application-layer\n  dedup with a configurable \"who wins\" comparator. Stable order.\n- `preferTenantRow(getOrgId)` — convenience comparator (non-NULL\n  orgId wins).\n\n4 new unit tests pin the dedup semantics. 11 i18n tests now (+4).\n\nWrite paths (upsert / delete) stay org-scoped — Phase C adds the\nplatform.i18n.* surface for the root-managed catalog writes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.526Z","updatedAt":"2026-06-16T17:53:13.526Z"},{"id":"874db98a-cafb-46a6-9dc7-a68439138af7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"routetree-regen-i18n-languages","type":"fixed","scope":"web","summary":"Regenerate route tree so /saas/i18n/languages registers in the SPA build.","body":"The `/saas/i18n/languages` route (added in `9d3c3e5c`) was never\nwritten into `apps/web/src/routeTree.gen.ts`. The route file was\nhealthy; the generator just hadn't been re-run after the merge.\n\nSame class of stale-tree bug that `3f17de17` fixed for\n`/saas/storage` + `/saas/mailbox` + `/saas/roadmap/$featureId`\nearlier today. Caught while diagnosing user-reported 404s on\nstaging — storage routes are already in the tree since\n`3f17de17`, so staging needs a fresh deploy; this commit\nincidentally surfaces the i18n one.\n\nRun `node apps/web/scripts/regen-route-tree.mjs` after adding\nany new route file so future merges don't drift the tree from\nthe filesystem.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.759Z","updatedAt":"2026-06-16T17:53:13.759Z"},{"id":"e2a688a8-66d6-46d0-95b9-11325448a494","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-drift-alert-email","type":"added","scope":"storage","summary":"Root operators now get a paging email when daily reconciliation flags storage drift >5%.","body":"Phase 3.6b of the unified Storage + Drive plan. Closes the loop\nopened by Phase 3.5 (reconciliation cron) + Phase 3.6 (event\nemission to the bus, commit `7b5f48b1`).\n\nWhen `runReconciliationSweep` detects drift ≥5% on any (org,\nprofile) tuple, the cron emits\n`storage.reconciliation.drift_alert`. This commit adds:\n\n1. **Template seed** — `platform.storage.drift_alert` in\n   `modules/email/src/seeds/templates/storage.ts` (new file).\n   The subject + body call out the affected org, profile,\n   formatted drift, the inventory source label (\"local-driver\n   inventory walk\" vs \"live DB rows\" vs S3/GCS/etc once Phase 4\n   wires those feeds), and a link to `/saas/storage`.\n\n2. **Flow registry entry** in `modules/email/src/seeds/flows.ts`\n   tagged `important: true` + `essential: true` — drift alerts\n   are paging notifications, not opt-out marketing.\n\n3. **Subscriber** — `modules/storage/src/jobs/email-on-drift-alert.ts`\n   subscribes to `storage.reconciliation.drift_alert`, queries\n   `users.type='root'`, and dispatches the email via the\n   `email.outbound.send` action through a `createSystemContext`\n   with `email:outbound:send`. Mirrors the recipient-resolution\n   pattern from `modules/status/src/jobs/notify-on-critical-incident.ts`.\n\n4. **Boot wiring** — `registerStorageJobs({ db })` now registers\n   the subscriber; called from `apps/worker/src/index.ts`\n   alongside the other module-jobs registrars.\n\nIdempotency: `idempotencyKey = storage.drift.<orgId>.<profileId>.<envelopeId>.<userId>`.\nThe email module's 24h dedup window collapses retries of the\nsame envelope; cross-day re-emits get a fresh envelope id daily.\n\nRecipient cascade matches status: `users.type='root'` filter\nreturns every operator. Counter is NOT auto-corrected at ≥5%\nseverity (Phase 3.5 sweep stops there) — the email tells the\noperator to decide whether the gap is a counter-update bug, a\nrunaway ingest, or a provider-side mismatch.\n\nThe `email_messages_outbound` row is the persistent record. The\nevent-bus subscriber's failures don't poison the cron's tick —\nthe storage_reconciliation_runs row is still written before the\nevent fires, so the operator can spot the drift in `/saas/storage`\neven if every email provider is down.\n\nNo new tests in this commit. The `seeds.test.ts` cross-validator\n(template ↔ flow registry sync) covers the template + flow\ncontract; the subscriber matches the existing\n`notify-on-critical-incident` shape verbatim. A dedicated\nintegration test of the subscriber would need an outbox harness\n(deferred — the status module's `notify-on-critical-incident`\nships without one too).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.805Z","updatedAt":"2026-06-16T17:53:13.805Z"},{"id":"2d79ede0-fba9-41e0-8e36-3cb7d1009689","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"i18n-phase-d-saas-i18n-ui","type":"added","scope":"web","summary":"i18n Phase D — `/saas/i18n/languages` admin route for the platform-tier locale catalogue.","body":"Fifth slice of the i18n platform-tier rollout. Phase D adds the\noperator-facing admin route — `/saas/i18n/languages` — where root\nusers manage the source-of-truth locale catalogue every tenant\ninherits.\n\nThe page mirrors the existing `/settings/languages` UX but targets\nthe new platform-tier action set:\n  - `platform.i18n.locale.list` — list `org_id IS NULL` rows.\n  - `platform.i18n.locale.upsert` — add or edit a row.\n  - `platform.i18n.locale.delete` — soft-delete (sets\n    `deleted_at`); tenants stop inheriting on next bundle fetch.\n  - `platform.i18n.catalog.seed` — one-click \"give me the 7\n    starter locales\" button shown when the catalogue is empty.\n\nLayout: a catalogue table at the top (Default / Code / Name /\nNative / Direction / Enabled / Actions) + an \"Add a locale\"\npicker below that searches the in-code `LOCALE_CATALOG` (96\nBCP-47 entries) and adds with one click. Mirrors the tenant\npage's column shape so operators trained on `/settings/languages`\nrecognise the surface immediately.\n\nThe SaaS module sub-nav gains a Languages entry in the\nConfiguration group (between Onboarding and Maintenance).\n\nPer-tenant translation overrides still flow through\n`/settings/translations` / `/settings/languages` unchanged —\nthe inheritance resolver (Phase B) reads both tiers; the\ntenant routes write to per-org rows; this new platform route\nwrites to `org_id IS NULL` rows.\n\nPhase F adds the consolidation tool for existing deployments\nthat have pre-rollout per-org locale rows duplicating the\nplatform catalogue.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["closed-loop"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T17:53:13.826Z","updatedAt":"2026-06-16T17:53:13.826Z"},{"id":"c51d9f79-1242-457e-ade1-8158232a2a2d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-oauth-exchange-actionable-error","type":"fixed","scope":"mailbox","summary":"Gmail / Microsoft OAuth code-exchange errors name the three root causes (redirect URI / client secret / code expired) and the exact values to verify — operator can self-diagnose instead of staring at \"Bad Request\".","body":"A user reported Gmail connect failing with:\n\n  > OAuth code exchange rejected (auth_expired):\n  > gmail.exchangeCode: invalid_grant: Bad Request\n\nThat message is technically accurate but operationally useless.\nGoogle returns `invalid_grant + \"Bad Request\"` for THREE distinct\nroot causes and the operator can't tell which one:\n\n  1. The redirect URI we sent is NOT registered on the OAuth\n     client in Google Console.\n  2. The client_secret is wrong (typo, stale after rotation, or\n     pasted from the wrong OAuth client).\n  3. The authorization code was already consumed (browser\n     prefetch, double-click) or expired (slow consent dance).\n\n## What changed\n\n`modules/mailbox/src/lib/oauth-code-exchange.ts` — when the\nprovider returns `invalid_grant` or `invalid_request`, the\n`ProviderError` message now spells out all three causes IN ORDER\nOF FREQUENCY along with the actionable values:\n\n  - The EXACT `redirect_uri` we sent — operators paste this into\n    Google Console / Entra Admin's \"Authorized redirect URIs\"\n    list.\n  - The first 8 chars of the client id (the public identifier\n    — non-secret) — operators verify the right OAuth client\n    is wired.\n  - Pointers to the operator surfaces (`/saas/mailbox` to fix\n    the client_secret, `/settings/mailbox` to re-initiate the\n    connect).\n\nThe verbatim provider response is preserved — operators who\nalready know the provider's error idiom (Microsoft's\n`AADSTS70008` etc.) can still pattern-match on it.\n\n## Structured log at the failure site\n\n`modules/mailbox/src/actions/connect-complete.ts` — the exchange\ncall site now writes BOTH:\n\n  - An `info` log BEFORE the exchange call: `redirectUri`,\n    `clientIdPrefix`, `clientIdSource` + `clientSecretSource`\n    (each is `platform_settings | env-fallback`) so operators\n    can confirm the dynamic-config rollout is wiring through.\n  - An `error` log on failure (alert tag\n    `mailbox.connect.complete.exchange_failed`) carrying the\n    same diagnostic context + the provider's raw error.\n\nTogether they let operators grep one alert tag and see exactly\nwhich credential source produced the failed request — far\nshorter triage than reading every connect attempt's audit log.\n\n## Reasoning on what we DON'T leak\n\n- The full client id is in the OAuth authorize URL (public) but\n  feels noisy in error messages. First 8 chars + ellipsis is the\n  shortest disambiguator.\n- The client_secret is NOT logged or surfaced — only its source\n  (DB vs env). The fix is \"go re-paste it\", not \"we'll show\n  you the wrong one\".\n- The authorization code is single-use and already consumed\n  before we hit this path; logging it is moot.\n\n## Tests\n\n`modules/mailbox/src/lib/oauth-code-exchange.test.ts` (new, 4 cases):\n\n  - invalid_grant → message names all three causes + the exact\n    redirect URI + the clientId prefix; does NOT leak the secret\n    or the full clientId.\n  - invalid_request → same actionable message (same root-cause set)\n  - 5xx provider failure → legacy short message (no scaffolding)\n  - Microsoft Graph path → same actionable shape end-to-end\n\n306 mailbox-module tests pass.\n\n## Operator runbook (for the next time this fires)\n\n1. Grep the worker / web logs for\n   `alert: mailbox.connect.complete.exchange_failed`.\n2. Note `redirectUri`, `clientIdPrefix`, `clientSecretSource`.\n3. Verify in Google Console / Entra Admin:\n   - The `redirectUri` value is in \"Authorized redirect URIs\"\n     (exact match, no trailing slash).\n   - The OAuth client whose id starts with `clientIdPrefix`\n     is the one wired at `/saas/mailbox`.\n4. If `clientSecretSource` is `platform_settings` → re-paste\n   the secret at `/saas/mailbox` (rotation, typo).\n5. If `clientSecretSource` is `env-fallback` → check the deploy\n   env var matches the Console.\n6. If all three check out, the user's code was probably\n   double-consumed — ask them to retry the connect.\n\n## Reference\n\n- Exchange helper: [modules/mailbox/src/lib/oauth-code-exchange.ts](../../modules/mailbox/src/lib/oauth-code-exchange.ts)\n- Wire site: [modules/mailbox/src/actions/connect-complete.ts](../../modules/mailbox/src/actions/connect-complete.ts)\n- Tests: [modules/mailbox/src/lib/oauth-code-exchange.test.ts](../../modules/mailbox/src/lib/oauth-code-exchange.test.ts)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T18:46:58.071Z","updatedAt":"2026-06-16T18:46:58.071Z"},{"id":"5a8f0428-5052-4bc3-b705-058597f88344","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"roadmap-attachment-storage-bridge","type":"changed","scope":"roadmap","summary":"Roadmap attachment upload now routes through storage.object.put_url instead of direct driver calls.","body":"Phase 6.1.4 of the unified Storage + Drive plan — first\nbrowser-presign consumer migration after the D-23 lock.\n\nBefore: `platform.roadmap.feature.create_upload_url` called\n`@helios/storage` directly via `createStorageClient()` +\n`presignUpload()`, bypassing the storage module entirely — no\n`storage_objects` row, no meter event, no quota gate, no\naccess-registry hook.\n\nAfter: the action calls `getAction('storage.object.put_url')` +\n`invoke()` via a `createSystemContext` with\n`platform:roadmap:manage`. The submitter actor carries\n`roadmap:feature:submit`, NOT the platform perm — the system\ncontext bridges the gap. Pattern mirrors the recruitment offer\npublic render in commit `5140d334`.\n\nKey shape preserved end-to-end:\n`_platform/roadmap/<objectId>/<filename>`. The storage module's\n`buildStorageKey` for platform purposes produces exactly that\nshape, so:\n- the marketing apex `/api/files/_platform/roadmap/...` proxy\n  URL keeps resolving for existing attachments;\n- the `_platform/roadmap/` prefix whitelist in\n  `apps/web/src/server/files-proxy.ts` keeps working;\n- no schema migration; no backfill.\n\nWhat the migration unlocks for new uploads:\n- a real `storage_objects` row (replaces the orphan-blob shape)\n- access-registry hook for future Drive surfacing\n- the quota-gate carve-out for platform purposes (G.1.2)\n  short-circuits the cap check for these bytes, so the\n  platform tenant is not billed for operator-owned roadmap\n  attachments\n- the platform-aware policy (G.1.3) ensures only platform-tier\n  perms (or root) can write under `_platform/`\n\nModule-specific pre-checks (content-type allowlist + 25 MiB\ncap) stay at the roadmap action layer — the storage module's\n`PutUrlInput` schema is intentionally more permissive (other\nconsumers have larger files / broader MIME). All 6 existing\nattachment unit tests pass verbatim.\n\nAll 142 roadmap module tests pass; zero typecheck errors.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T18:46:58.384Z","updatedAt":"2026-06-16T18:46:58.384Z"},{"id":"7e215b94-8382-4bfb-a1b1-dda488ec0069","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-drift-alert-emission","type":"added","scope":"storage","summary":"Storage reconciliation cron now emits storage.reconciliation.drift_alert when drift exceeds 5%.","body":"Phase 3.6 of the unified Storage + Drive plan — wires event\nemission to the reconciliation sweep that landed in Phase 3.5\n(commit `d80c62be`).\n\nPreviously the sweep recorded its verdict to\n`storage_reconciliation_runs` + surfaced `driftAlertsRaised` in\nthe return value, but no domain event hit the bus. Operators had\nto read the admin UI to discover drift. This commit closes the\nloop by emitting `storage.reconciliation.drift_alert` per raised\nalert so subscribers (the upcoming `email-on-drift-alert`\nsubscriber in Phase 3.6b) can fan-out operator notifications.\n\nImplementation:\n\n- The sweep's `driftAlertsRaised` array now carries the per-tuple\n  `source` ('local_walk' when the local driver inventory was\n  walked, 'rows_only' when the live-rows sum stood in as truth).\n  Lets the alert email render honestly: \"drift against S3\n  inventory\" vs \"drift against live DB rows\".\n- The cron iterates `driftAlertsRaised` after `runReconciliationSweep`\n  returns and emits the event via `createSystemContext` per alert\n  (per-org scope; emission failures don't poison the loop — the\n  storage_reconciliation_runs row is the persistent record).\n- Existing event class `storage.reconciliation.drift_alert` was\n  declared in Phase 2D's events index; this commit is the first\n  emitter.\n\nThe event payload matches the schema declared at\n`modules/storage/src/events/index.ts:186-195`:\n\n```\n{\n  orgId: uuid,\n  profileId: uuid,\n  driftBytes: int,\n  driftPct: number,\n  source: string,\n}\n```\n\nTest:\n- The existing \"raises a drift alert\" PGlite integration test was\n  extended with one assertion (`expect(alert.source).toBe('rows_only')`).\n  No new test cases — the event emission happens in the cron\n  wrapper which is exercised in production; an integration test\n  of the cron wrapper itself would require an outbox harness and\n  is out of scope for this small wiring commit.\n\nPhase 3.6b will land the `email-on-drift-alert` subscriber + the\n`platform.storage.drift_alert` email template seed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T18:46:58.387Z","updatedAt":"2026-06-16T18:46:58.387Z"},{"id":"cd26e65a-217c-4596-ab80-2551bd9a3e53","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-object-write-platform-aware-policy","type":"added","scope":"storage","summary":"storage.object.put_url + put now gate platform-purpose writes by platform-admin perms (not tenant storage:object:write:own).","body":"Phase G.1.3 of the unified Storage + Drive plan. Closes the\nfinal pre-requisite gap before the Phase 6.3.1 platform-branding\nmigration can land.\n\nNew policy `objectWritePlatformAwarePolicy` replaces\n`objectWritePolicy` on `storage.object.put_url` +\n`storage.object.put`. Branch on `input.purpose`:\n\n| Purpose tier | Required permission |\n|---|---|\n| Tenant (sales_invoice, hrm_document, mailbox, …) | `storage:object:write:own` (unchanged) |\n| `platform_branding` | `platform:app:update` |\n| `platform_roadmap` | `platform:roadmap:manage` |\n| `platform_status` | `platform:status:manage` |\n| `platform_changelog_media` | `platform:changelog:manage` |\n\nWhy this matters:\n\n1. **Platform-branding admins don't hold `storage:object:write:own`**\n   today (it's a tenant blueprint perm). Without the widening,\n   the migration of `platform.asset.upload_url` to\n   `storage.object.put_url` would lock platform admins out of\n   their own branding upload.\n\n2. **Symmetric protection.** A tenant user holding\n   `storage:object:write:own` is now explicitly DENIED from\n   writing under any `_platform/` key prefix. The old policy\n   accepted them. This closes a confused-deputy class: tenant\n   roles can no longer scribble into the platform's shared\n   branding namespace just because they could presign for\n   their own files.\n\n3. **Root unchanged.** Root operators hold every permission\n   via the all-permissions set; this widening is a no-op for\n   them.\n\n`confirm_upload` and `delete` continue on `objectWritePolicy`\n/ `objectDeletePolicy` — they reference an existing row, not\nan input.purpose, and their handlers already guard cross-tenant\naccess via the orgId check. A defensive `PLATFORM_PURPOSE_PERMS`\nfallback denies if a future platform purpose is added to\n`purposes.ts` without updating the policy's permission map\n(failing closed) — pinned by a test.\n\n11 policy unit tests cover every branch including the\ncounterfactual: a tenant-perm holder denied at a platform\npurpose, a platform-perm holder denied at a tenant purpose,\nmismatched platform perms (status admin can't upload roadmap\nattachments), and a root-like actor passing every purpose.\n\nAll 115 storage unit tests pass + zero typecheck errors on\ntouched files.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T18:46:58.661Z","updatedAt":"2026-06-16T18:46:58.661Z"},{"id":"69c98f90-65fa-4436-bc76-88e89f3031f7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-inbound-captcha","type":"added","scope":"forms","summary":"Inbound forms can require a CAPTCHA — verify Turnstile / hCaptcha / reCAPTCHA / CaptchaFox tokens server-side, fail-closed on missing or rejected.","body":"Phase D residual of the forms↔websites integration plan\n([docs/plans/FORMS_WEBSITE_INTEGRATION_SPEC.md](../../docs/plans/FORMS_WEBSITE_INTEGRATION_SPEC.md)).\nLayered on top of the existing inbound token + HMAC signing, this\nadds a CAPTCHA passthrough so a public-facing partner site can\ndeflect bots before they reach the lead pipeline.\n\nWhat landed:\n\n- **Schema** — `FormDefinition.inbound.captcha = { provider,\n  secretKey, required? }` in [packages/forms/src/types.ts](../../packages/forms/src/types.ts).\n  Pure jsonb body — no migration. `provider` is one of the four\n  configured in the platform auth-providers catalogue\n  (`cloudflare-turnstile`, `hcaptcha`, `google-recaptcha`,\n  `captchafox`).\n- **Verifier** — `extractCaptchaToken(provider, header, payload)` +\n  `verifyCaptchaToken({ provider, secret, token, remoteIp,\n  fetcher, timeoutMs })` in\n  [packages/forms/src/inbound.ts](../../packages/forms/src/inbound.ts).\n  Token sources, in priority order: the `X-Captcha-Token`\n  header, then each provider's widget body field\n  (`cf-turnstile-response`, `h-captcha-response`,\n  `g-recaptcha-response`, `cf-captchafox-response`), then a\n  generic `captchaToken` / `captcha-token` / `captcha_response`\n  fallback. Net/parse errors collapse to a single `{ ok: false,\n  reason }` shape so the handler maps them all to one HTTP 401.\n- **Handler enforcement** —\n  [apps/web/src/server/forms-inbound.ts](../../apps/web/src/server/forms-inbound.ts)\n  runs the CAPTCHA gate after the optional HMAC, before\n  field-mapping. Failures log `inbound_rejected` with the\n  provider's reason code so operators see the same surface as\n  signature failures.\n- **Builder UI** —\n  [apps/web/src/components/form-builder-inbound.tsx](../../apps/web/src/components/form-builder-inbound.tsx)\n  gained a \"CAPTCHA (advanced)\" panel that mirrors the signing\n  block: pick provider, paste secret, toggle required, copy\n  the docs.\n- **Tests** — 10 new unit tests in\n  [packages/forms/src/inbound.test.ts](../../packages/forms/src/inbound.test.ts)\n  pin token extraction, provider routing, success/deny/HTTP-error\n  paths, and the network-error fallback. All 241 package tests\n  pass.\n\nOperator workflow: the partner site renders its existing CAPTCHA\nwidget (Helios doesn't bundle one for them — they ALREADY have it\nbecause their compliance posture said so), the widget token rides\ninto our inbound POST under one of the documented names, the\nhandler verifies it against the stored secret, and bots without a\nreal token are 401'd before they touch `forms.public.submit`.\n\nBackwards-compatible: when `inbound.captcha` is absent (the\ndefault), behaviour is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T19:15:45.956Z","updatedAt":"2026-06-16T19:15:45.956Z"},{"id":"7dc9a064-7ada-406a-8f88-7c465b5e27e3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-attachment-lazy-mirror-write","type":"added","scope":"mailbox","summary":"Mailbox attachments lazy-mirror to storage on first download — cached row + storage_objects entry land best-effort behind the mailbox.attachment.cache_enabled plan feature.","body":"Phase 3 commit 2 of the email + mailbox → unified storage\nmigration. The mailbox attachment proxy now mirrors downloaded\nbytes into the unified storage module on the first download and\ninserts a `mailbox_attachment_cache` row. Subsequent reads\n(Phase 3 commit 3) will hit the cache + redirect to a presigned\nURL instead of round-tripping the provider every time.\n\n## What changed\n\n`apps/web/src/server/mailbox-attachment.ts`:\n\n- New `mirrorAttachmentBestEffort` helper. After the user's\n  download succeeds, fire-and-forget mirrors the bytes via\n  `getAction('storage.object.put') + invoke()` with\n  `purpose: 'mailbox'`. On success, inserts a row in\n  `mailbox_attachment_cache` keyed by\n  `(account_id, provider_message_id, provider_part_id)`.\n\n- Plan-gated by `mailbox.attachment.cache_enabled` (Phase 1\n  commit 6 catalog entry). Off for free/starter; on for\n  business+. When off, the proxy keeps doing a per-request\n  provider round-trip with no cache write.\n\n- Best-effort: any storage / cache-write failure is logged with\n  structured context but does NOT fail the user's download — they\n  already received the bytes via the existing response stream.\n\n## Ownership shape\n\n**Personal scope** — the row carries:\n\n  org_id        = PLATFORM_PERSONAL_SCOPE_ORG_ID (the sentinel)\n  owner_kind    = 'user'\n  owner_user_id = account.userId\n  owner_module  = null\n\nThe Q15 fence at the access registry (Phase 1 commit 5; explicit\ntest lock in Phase 3 commit 4) refuses non-owner reads of\nsentinel-org rows. Platform permissions do NOT bypass.\n\n**Business scope** — the row carries:\n\n  org_id        = account.orgId\n  owner_kind    = 'module'\n  owner_module  = 'mailbox'\n  owner_user_id = null\n\nThe org pays the storage cost because the mailbox itself is\norg-provisioned.\n\n## Plan-feature resolution\n\nThe feature toggle is queried against:\n\n- `account.orgId` for business-scope accounts\n- `PLATFORM_PERSONAL_SCOPE_ORG_ID` for personal-scope accounts\n\nPersonal-scope caching is platform-tier: operators decide\nglobally whether to mirror personal mailbox attachments to disk\nby flipping the toggle on the sentinel org. Per-tenant gating\ndoesn't apply because the storage row lives in the platform\nnamespace, not any tenant's.\n\n## Idempotency\n\nPer-attachment idempotency key:\n\n  mailbox.attachment.<accountId>.<msgId>.<partId>\n\nConcurrent first-downloads collapse via:\n1. `storage.object.put`'s D-11 sha256 dedup (refcount-bump on\n   matching bytes within the org)\n2. The cache row's `(account, message, part)` unique index from\n   Phase 3 commit 1 (`ON CONFLICT DO NOTHING`)\n\n## Boundary respected\n\nThe proxy calls the storage action via `getAction()` + `invoke()`\n— no direct `@helios/storage-module` action imports. Web app\ngains `@helios/storage-module` as a workspace dep so the\nsentinel constant + types are available without coupling to\naction handlers directly.\n\n## Tests\n\n`modules/storage/src/lib/mailbox-attachment-mirror.integration.test.ts`\n(4 PGlite cases exercising the same put + cache write path the\nproxy uses):\n\n- plan-gate OFF → no storage object, no cache row\n- plan-gate ON, personal scope → sentinel org + owner_kind=user\n- plan-gate ON, business scope → account.orgId + owner_kind=module\n- idempotent: second invocation collapses via sha256 + unique index\n\n127 storage integration + 309 mailbox-module tests pass.\n\n## Operator notes\n\nAfter deploy, attachments downloaded from a Business+ org's\nmailbox will start populating `storage_objects` rows with\n`purpose='mailbox'`. The `last_accessed_at` column on the cache\ntable will be hit by the read path (Phase 3 commit 3); for now\nit carries the create timestamp.\n\nStorage usage telemetry shows the new bytes under the\n`mailbox` purpose. The retention reaper does NOT yet sweep the\ncache — that lands with the eviction cron in Phase 3 commit 5.\n\n## What's next in Phase 3\n\n- Commit 3: cache read path — on cache hit, `storage.object.get_url`\n  + bump `last_accessed_at`. Redirect the browser to the\n  presigned URL.\n- Commit 4: Q15 fence test lock at access registry — explicit\n  assertion that sentinel-org rows reject non-owner reads even\n  with `platform:storage:usage:read`.\n- Commit 5: cache eviction on mailbox disconnect — soft-delete\n  cache rows + invoke `storage.object.delete` so refcount\n  decrements cleanly.\n\n## Reference\n\n- Proxy: [apps/web/src/server/mailbox-attachment.ts](../../apps/web/src/server/mailbox-attachment.ts)\n- Sentinel: [modules/storage/src/lib/sentinels.ts](../../modules/storage/src/lib/sentinels.ts)\n- Plan feature: [modules/saas/src/lib/feature-catalog.ts](../../modules/saas/src/lib/feature-catalog.ts) (`mailbox.attachment.cache_enabled`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T20:27:41.923Z","updatedAt":"2026-06-19T20:27:41.923Z"},{"id":"9f0558d1-2147-434b-874c-8c51f668dfee","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-profile-verify-failed-email","type":"added","scope":"storage","summary":"Root operators get a paging email when a storage profile flips from verified to verify_failed.","body":"Phase G.2.3 of the unified Storage + Drive plan. Wires the\nsecond orphan event (`storage.profile.verified`) to its\nsubscriber so the daily verify cron's failure flips actually\nreach an operator.\n\nWhen the storage-profile verify cron (`Phase 3D-2`) runs its\nPUT → HEAD → GET → DELETE → LIST roundtrip and a profile\nflips from `verified` → `verify_failed`, the\n`storage.profile.verified` event now drives an email to every\n`users.type='root'` operator. Catches the\n\"BYOB credentials expired six weeks ago and nobody noticed\"\nfailure mode that's typically a P1 from on-call.\n\nThree moving parts:\n\n1. **Template seed** — `platform.storage.profile.verify_failed`\n   in `modules/email/src/seeds/templates/storage.ts`. Subject +\n   body call out the profile name, driver, captured verify\n   error, and a link to `/saas/storage`.\n\n2. **Flow registry entry** in `modules/email/src/seeds/flows.ts`\n   tagged `important: true` + `essential: true`. The flow\n   docstring explicitly notes the subscriber filters on\n   `status='verify_failed'` so happy-path verifies don't spam.\n\n3. **Subscriber** —\n   `modules/storage/src/jobs/email-on-profile-verify-failed.ts`\n   subscribes to `storage.profile.verified`, short-circuits on\n   `status='verified'`, queries `users.type='root'`, and\n   dispatches via `email.outbound.send` through a\n   `createSystemContext` with `email:outbound:send`. Mirrors\n   the recipient-resolution pattern from `email-on-drift-alert.ts`.\n\nBoot wiring — `registerStorageJobs({ db })` now registers two\nsubscribers (drift_alert + profile_verify_failed).\n\nIdempotency:\n  `storage.profile.verify_failed.<profileId>.<envelopeId>.<userId>`\nThe email module's 24h dedup collapses retries of the same\nenvelope; cross-day re-fires (verify cron probes daily) get a\nfresh envelope id so chronic failures still ring through.\n\nAll 278 email-module tests pass (seeds.test.ts cross-validator\nconfirms template ↔ flow registry sync).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T18:46:58.667Z","updatedAt":"2026-06-16T18:46:58.667Z"},{"id":"dce7be36-67f1-4b67-afbb-852b228c4c4b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"storage-quota-platform-purpose-carve-out","type":"fixed","scope":"storage","summary":"requireStorageQuotaHeadroom short-circuits on platform-tier purposes (branding / roadmap / status / changelog media).","body":"Phase G.1.2 of the unified Storage + Drive plan. Platform-tier\npurposes (`platform_branding`, `platform_roadmap`,\n`platform_status`, `platform_changelog_media`) land with\n`org_id = PLATFORM_ORG_ID` — the plan-cap resolver has no\nmeaningful answer for the platform tenant, and per the\nintegration guide §\"platform branding\": \"Reads are public; no\nquota gating.\"\n\n`requireStorageQuotaHeadroom` now short-circuits to ok BEFORE\nany limits / counter / addon read when `isPlatformPurpose(purpose)`.\nThe orphan-sweep cron + admin observability still cover these\nbytes — they just don't burn against a tenant quota.\n\nUnblocks Phase 6.3.1 (platform branding migration onto\n`put_url + confirm_upload`).\n\n6 unit tests pin the behavior including a DB-exploder proxy\nthat proves the short-circuit fires BEFORE any DB access for\nplatform purposes, AND a counterfactual tenant-purpose test\nthat proves the carve-out is not over-eager.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T18:46:58.898Z","updatedAt":"2026-06-16T18:46:58.898Z"},{"id":"b8c0385e-36c6-4edf-9793-c4f7e41e8808","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-notif-href-client-rewrite","type":"fixed","scope":"web","summary":"Portal clients now land in /account/messages when they click chat notifications, instead of the staff /chat/* route.","body":"Phase 3 of the chat-client-portal integration plan\n([docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md](../../docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md)).\n\nBefore this change, every chat notification — sidebar item click,\ntoast Open action, OS-level browser notification — navigated to\n`/chat/<channelId>?focus=<messageId>`. That route only exists in\nthe staff shell; clients in the `/account/*` portal hit a 404 and\nhad no way back to the conversation.\n\nThe fix is a `users.type === 'client'` check driven by the new\n[apps/web/src/lib/me.ts](../../apps/web/src/lib/me.ts) `isClientMe`\nhelper, plus a `rewriteHrefForClient` rewriter in\n[apps/web/src/components/notifications-bell.tsx](../../apps/web/src/components/notifications-bell.tsx)\nthat turns `/chat/<channelId>[?focus=<messageId>]` into\n`/account/messages?channelId=...[&focus=...]` for portal users.\nStaff (type !== 'client') see the existing behaviour unchanged.\n\nApplied to the three navigation sites in the bell component:\nthe realtime toast Open action, the dropdown item click, and the\nWS-realtime envelope handler that drives the OS notification.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T19:15:45.036Z","updatedAt":"2026-06-16T19:15:45.036Z"},{"id":"6e19ceea-71ff-4d7f-a48e-5c801485984e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-inbound-receive-storage-object-id","type":"added","scope":"email","summary":"email.inbound.receive accepts storageObjectId attachments and writes structured email_inbound_attachments join rows alongside the legacy jsonb.","body":"Phase 2 commit 2 of the email + mailbox → unified storage\nmigration. Mirrors Phase 1 commit 8 (`a4654ae1`) on the inbound\nside. Webhook controllers (Postmark / Mailgun / SES SNS) and the\nupcoming IMAP poll now have a structured place to land attachment\nbytes; the inbound viewer + future audit surfaces read from the\njoin table directly.\n\n## What changed\n\n`modules/email/src/schemas/inbound.ts` — `InboundAttachment`\ngains four optional fields mirroring outbound:\n\n- `storageObjectId` — canonical: webhook / poller has already\n  uploaded bytes\n- `contentId` — RFC 2392 (inline images in HTML body)\n- `disposition` — RFC 2183 `'attachment' | 'inline'`\n- `displayFilename` — recipient-visible name override\n\nAnd one new top-level field:\n\n- `rawMimeObjectId` — FK to a `storage_objects` row holding the\n  raw RFC 5322 source. Phase 2 commit 3 (archiver migration) will\n  populate this; the receive handler passes it through.\n\n`modules/email/src/actions/inbound.ts` — new\n`materialiseInboundAttachments` helper:\n\n- `storageObjectId` entries pass through unchanged.\n- `contentBase64` entries → `getAction('storage.object.put')` +\n  `invoke()` with `purpose: 'email_inbound'`,\n  `ownerKind: 'module'`, `ownerModule: 'email'`,\n  `idempotencyKey: email.inbound.<messageId>.attachment.<idx>`.\n  The materialised entry replaces the inline base64 in the\n  persisted jsonb.\n- `storageKey`-only entries carry through unchanged (legacy —\n  no join row).\n\nPlus `writeInboundAttachmentJoinRows` — inserts join rows for\nevery entry that resolved to a `storageObjectId`. Best-effort;\nfailures fall back to the legacy jsonb dual-read.\n\n`hasRawMime` in the inbound list response now returns true for\nEITHER `rawMimeObjectId` OR the legacy `rawMimeObjectKey`. The\nadmin \"Download .eml\" button works in both shapes during the\nPhase 2 cutover.\n\n## Dedup interaction\n\nThe existing Message-ID dedup runs BEFORE materialisation — so a\nwebhook retry of the same RFC 5322 Message-ID short-circuits to\n`{ id, isNew: false }` without re-calling `storage.object.put`.\nOn top of that, the storage action's D-11 sha256 dedup collapses\nidentical bytes across orgs / messages into one refcount-bumped\nrow.\n\n## Failure handling\n\nThe receive handler is BEST-EFFORT on storage failures:\n\n- Storage module not loaded (lightweight script / test) →\n  `getAction` returns undefined → attachment carries through\n  unchanged. Legacy jsonb path keeps working.\n- `storage.object.put` errored (provider down / quota / etc.) →\n  logged with structured context; attachment carries through.\n- Inbound dispatch + the `email.inbound.received` event ALWAYS\n  fire — a webhook retry storm caused by a storage outage would\n  be operationally worse than a degraded attachment write.\n\n## Tests\n\n`modules/storage/src/lib/email-inbound-receive-attachment-shape.integration.test.ts`\n(6 PGlite cases through the real `email.inbound.receive`):\n\n- contentBase64 → materialised + jsonb has objectId + base64 stripped\n- storageObjectId passes through cleanly — join + cid + disposition preserved\n- storageKey-only carries through jsonb only, no join row\n- multiple inline + attachment shapes preserve cid + ordering\n- Message-ID dedup short-circuits before materialisation\n- rawMimeObjectId input passes through to the inbound row\n\n105 storage integration + 278 email module tests pass.\n\n## What's next in Phase 2\n\n- Commit 3: raw-MIME archiver migrates from the injected seam\n  (`apps/web/src/server/email-mime-archiver.ts`) to\n  `storage.object.put { purpose: 'email_raw_mime' }` and writes\n  `raw_mime_object_id`.\n- Commit 4: inline-image HTML rewriter substitutes `cid:foo@x`\n  with a short-TTL `storage.object.get_url` when the inbound\n  viewer renders.\n\n## Reference\n\n- Schema: [modules/email/src/schemas/inbound.ts](../../modules/email/src/schemas/inbound.ts)\n- Action: [modules/email/src/actions/inbound.ts](../../modules/email/src/actions/inbound.ts) (`materialiseInboundAttachments`, `writeInboundAttachmentJoinRows`)\n- Schema (DB): [packages/db/src/schema/email.ts](../../packages/db/src/schema/email.ts) (`emailInboundAttachments`, Phase 2 commit 1)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T19:15:45.872Z","updatedAt":"2026-06-16T19:15:45.872Z"},{"id":"7c8344dc-30de-478d-aebb-e93f438c4af3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-portal-composer-mode","type":"changed","scope":"web","summary":"Portal client composer hides the \"Link work\" entity picker + ephemeral popover so clients can't surface staff-only entity ids.","body":"Phase 5 of the chat-client-portal integration plan\n([docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md](../../docs/plans/CHAT_CLIENT_PORTAL_INTEGRATION.md)).\n\nBefore this change, the `/account/messages` composer rendered\nthe same toolbar as the staff `/chat/*` shell. Two affordances\nin particular leaked information clients shouldn't access:\n\n- **\"Link work · deals, tasks, contacts…\"** — opens an entity\n  picker against the user's full work surface. For staff this\n  is the right behavior; for a portal client it would surface\n  ids (and at minimum names) of deals + tasks + contacts they\n  have no business referencing.\n- **Ephemeral compose popover** — a staff moderation tool\n  (\"send a one-off message only some members see\"). Not part\n  of the portal-client contract.\n\nThe fix: a new `mode?: 'staff' | 'portal'` prop on `<ChannelView>`\nthat threads down to `<ComposerTiptap>`. `/account/messages`\npasses `mode=\"portal\"`; the staff routes leave the default. The\ntwo affordances render only when `mode !== 'portal'`. Other\ncomposer features (attachments, voice notes, polls, smart compose,\nmentions, formatting, emoji) all stay intact — clients can still\nparticipate as first-class members of their space.\n\nNo new actions; pure prop-drilled UI gating.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T19:15:45.895Z","updatedAt":"2026-06-16T19:15:45.895Z"},{"id":"7dade8ed-d774-48d3-a35c-26a6f2bbdfe0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-client-portal-deep-link-polish","type":"fixed","scope":"web","summary":"Chat deep links from notifications + bookmarks now land portal clients on the right channel + message inside /account/messages.","body":"Polish pass on the chat-client-portal integration following the\nyesterday-shipped Phase 3 (`dd5dd9a4`). Three issues surfaced\nonce the notification rewrite started flowing end-to-end:\n\n1. **Param-name mismatch.** Phase 3's `rewriteHrefForClient`\n   emitted `?channelId=…` but\n   [apps/web/src/routes/account.messages.tsx](../../apps/web/src/routes/account.messages.tsx)\n   reads `?channel=…`. The deep link silently fell through to\n   the space's first channel. Fixed the rewriter to emit\n   `channel`.\n\n2. **`focus` was dropped on the floor.** The route schema only\n   accepted `channel`, so the message-anchor messageId from a\n   chat notification was discarded before reaching\n   `<ChannelView>`. Added `focus` to the route's\n   `validateSearch` schema and threaded it through to the\n   `focusMessageId` prop ChannelView already supports — so a\n   client clicking a notification now scrolls to the exact\n   referenced message, same as the staff route.\n\n3. **`/chat/*` redirect for clients pointed at `/account` index.**\n   The Phase 3 hard-redirect (D-D) bounced clients to their\n   dashboard instead of straight to the portal-shaped chat view.\n   Now lands on `/account/messages`. Implementation switched\n   from `<Navigate>` to `navigate(...)` in an effect because\n   TanStack's strict typing of `<Navigate>` against the\n   destination's required `validateSearch` shape was harder to\n   satisfy than the equivalent imperative call.\n\nEnd-to-end: a portal client clicking any chat notification —\ntoast, dropdown, OS-level — lands on the correct space, correct\nchannel, correct message anchor, all inside the `/account/*`\nshell. No more 404s, no more \"wrong channel\" surprises, no more\ndropped scroll position.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T19:15:45.915Z","updatedAt":"2026-06-16T19:15:45.915Z"},{"id":"e43dbbad-24d8-4d3d-b02a-fbf07fa2d5ee","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-web-provider-runtime","type":"fixed","scope":"mailbox","summary":"Compose-and-send + any web-process mailbox action stops failing with \"Provider 'gmail' is not registered in the mailbox-sync runtime\" — the web app now registers the same providers the worker does.","body":"User reported the compose modal toast:\n\n  > Provider 'gmail' is not registered in the mailbox-sync runtime.\n\nThe send (and any other web-process mailbox action that resolves a\nprovider via `requireMailboxProvider(...)`) was failing because the\n`MailboxSyncRuntime` singleton in `packages/mailbox-sync` was only\npopulated in the WORKER process. The web process never called\n`registerMailboxJobs({db})`, so the providers map was empty there.\n\n## What changed\n\n`apps/web/src/server/api.ts` — new boot-time IIFE alongside the\nexisting `seedSystemTemplates` / `seedSystemUser` /\n`seedProviderDescriptors` block. Calls `registerMailboxJobs({db})`\nwhich writes the four providers (gmail / outlook_oauth / imap /\noffice365_imap) into the `mailbox-sync` runtime singleton.\n\nSame call shape the worker already uses at\n`apps/worker/src/index.ts:434`. Idempotent — last write wins; the\nworker's own registration on its boot is harmless.\n\n## Why both processes need it\n\nThe mailbox-sync runtime is a process-global singleton. Each Node\nprocess — web request server AND worker cron — has its own copy.\nActions resolved on the request thread (compose-and-send, manual\nsync trigger, fetch-attachment) need `requireMailboxProvider()` to\nwork; without registration, the helper throws the user-visible\nerror above.\n\n## Operator note\n\nAfter this deploy the web process logs a confirmation line at\nboot:\n\n  > web: mailbox-sync provider runtime registered\n  > (gmail / outlook_oauth / imap / office365_imap)\n\nIf you see the failure path instead:\n\n  > web: mailbox-sync provider runtime registration failed\n  > (non-fatal) — mailbox send / sync actions will fail with\n  > \"Provider not registered\"\n\n…check the worker logs for the same registration since the\nmodules are identically wired.\n\n## Reference\n\n- Wire site: [apps/web/src/server/api.ts](../../apps/web/src/server/api.ts)\n- Provider registrar: [modules/mailbox/src/jobs/index.ts](../../modules/mailbox/src/jobs/index.ts) (`registerMailboxJobs`)\n- Runtime helper: [packages/mailbox-sync/src/runtime.ts](../../packages/mailbox-sync/src/runtime.ts) (`requireMailboxProvider`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T19:15:45.933Z","updatedAt":"2026-06-16T19:15:45.933Z"},{"id":"ec0d69e6-bf3a-4019-9964-ecbd58780777","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-inbound-cid-rewriter","type":"added","scope":"email","summary":"Inline-image cid: rewriter — substitutes cid:foo@x references in inbound HTML with short-TTL storage.object.get_url presigned URLs.","body":"Phase 2 commit 4 of the email + mailbox → unified storage\nmigration — and the FINAL commit that closes Phase 2 (email\ninbound → unified storage).\n\nInbound HTML email frequently references inline images via RFC\n2392 `cid:<contentId>` URLs. When a viewer renders the body,\nthose references need to become real fetchable URLs — or the\nrecipient sees broken image placeholders. The Phase 2 commit 2\nreceive handler already populates\n`email_inbound_attachments.content_id` for every inline\nattachment; this commit ships the rewriter that joins those rows\nwith the HTML body and produces a render-ready string.\n\n## What changed\n\nNew `modules/email/src/lib/inbound-html-rewriter.ts` with two\nentry points:\n\n- **`rewriteCidReferences(html, cidMap)` — pure, sync.** Scans\n  HTML for `cid:` references — quoted attrs, unquoted attrs, CSS\n  `url(...)`, the RFC-2392 bracketed `cid:<X>` form — and\n  substitutes each value with `cidMap.get(value)` when present.\n  Reports `cidsResolved` + `cidsMissing` so viewers can decide\n  whether to surface a \"some images couldn't be loaded\" banner.\n  Trivially testable; 12 unit cases cover the matching shapes,\n  case-sensitivity rules, multiple-ref dedup, and angle-bracket\n  balancing.\n\n- **`resolveAndRewriteInlineImages({db, messageId, html, ctx})`\n  — async orchestrator.** Looks up `email_inbound_attachments`\n  join rows for `messageId` where `content_id IS NOT NULL`,\n  invokes `getAction('storage.object.get_url')` per cid (default\n  TTL 600s — covers slow-network reloads), then calls the pure\n  rewriter. Returns the rewritten body + per-cid diagnostics +\n  a `storageActionUnavailable` flag.\n\nBoth are re-exported from the email module's root:\n\n```ts\nimport { resolveAndRewriteInlineImages } from '@helios/email-module';\n```\n\n## Best-effort failure handling\n\n- `storage.object.get_url` not registered → returns the HTML\n  untouched + `storageActionUnavailable: true`. Viewers see\n  broken images, NOT a broken page.\n- Single presign call fails → that cid stays as `cid:<value>`\n  in the body; the rest still resolve. Failure is logged with\n  `messageId` + `contentId` for triage.\n- No join rows for the message → returns the HTML; every cid:\n  ref in the body is reported in `cidsMissing` so callers can\n  log telemetry for \"sender embedded a cid we don't have.\"\n\n## Matching shapes covered\n\nThe regex covers every `cid:` shape this codebase has seen\ninbound:\n\n```\nsrc=\"cid:logo@x\"            → quoted double\nsrc='cid:logo@x'            → quoted single\nsrc=cid:logo@x              → unquoted attr (rare but valid)\nsrc=\"cid:<logo@x>\"          → RFC 2392 bracketed\nstyle=\"background:url(cid:bg@x)\"   → CSS url()\n```\n\nContent-ID matching is case-SENSITIVE per RFC 2392 §3, even\nthough the `cid:` prefix itself matches case-insensitively\n(some legacy clients use `CID:`).\n\n## Tests\n\n- `modules/email/src/lib/inbound-html-rewriter.test.ts` — 12\n  unit cases for the pure rewriter.\n- `modules/storage/src/lib/email-inbound-cid-rewriter.integration.test.ts`\n  — 5 PGlite cases through the real orchestrator with the storage\n  action stack.\n\n115 storage integration + 290 email module tests pass.\n\n## What's next\n\nThe pure rewriter helper is now available for the inbound viewer\nsurfaces. Wiring it into a specific viewer (admin inbound page,\nmailbox client UI, etc.) is left to whichever surface needs it\nfirst — the helper has no opinions about surface or markup.\n\n## Phase 2 status — COMPLETE\n\nAll four Phase 2 commits shipped:\n- `b021e134` (1) — inbound schema (join table + raw_mime FK + deleted_at)\n- `f9daf639` (2) — `receive` materialises contentBase64 + writes join rows\n- `696aa4a8` (3) — raw-MIME archiver writes via `storage.object.put`\n- this commit (4) — inline-image cid: rewriter\n\nThe inbound side of the migration is now functionally complete:\nnew inbound rows land structured storage objects + join rows\nalongside the legacy jsonb dual-read; viewers can render\ninline-image HTML correctly. Phase 3 (mailbox lazy-mirror cache)\nis the next chunk per the deep-dive plan.\n\n## Reference\n\n- Pure rewriter: [modules/email/src/lib/inbound-html-rewriter.ts](../../modules/email/src/lib/inbound-html-rewriter.ts)\n- Re-export: [modules/email/src/index.ts](../../modules/email/src/index.ts)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T20:38:06.742Z","updatedAt":"2026-06-16T20:38:06.742Z"},{"id":"e97fbd9b-f7ab-472e-b783-827ecb0a304b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-sync-hang-fix-and-stuck-claim-recovery","type":"fixed","scope":"mailbox","summary":"Mailbox stays stuck at \"Synced 1/1/1970\" no more — Gmail/Graph fetches gain a 30s timeout and the sync cron auto-recovers rows from a stuck epoch claim sentinel.","body":"A user reported their newly-connected Gmail account showing\n\"Synced 1/1/1970\" in the inbox UI and never populating with\nmessages. Tracing the worker logs surfaced a chain of THREE\ndistinct bugs that combined to keep the row stuck forever.\n\n## Bug 1 — Gmail / Graph clients had no fetch timeout\n\n`packages/mailbox-sync/src/providers/gmail/client.ts` and\n`graph/client.ts` called `fetchImpl(url, {...})` without an\n`AbortSignal`. Node's undici fetch defaults to **no timeout** —\nan unresponsive TCP socket sits forever. The bootstrap\ngenerator's `await this.client.request(...)` blocks indefinitely.\n\nFix: thread a `requestTimeoutMs` opt (default 30 000) through\nboth client constructors. The fetch call now uses\n`AbortSignal.timeout(this.requestTimeoutMs)` which forces the\nunderlying socket to tear down. A timeout surfaces as a\n`ProviderError(kind: 'network')` which bubbles up to\n`runMailboxSyncClaim`'s catch → `markFailed` reverts\n`last_synced_at` to NULL → next tick re-claims and retries.\n\nNew constants:\n- `GMAIL_DEFAULT_REQUEST_TIMEOUT_MS = 30_000`\n- `GRAPH_DEFAULT_REQUEST_TIMEOUT_MS = 30_000`\n\n## Bug 2 — Cron's busy flag never cleared after a hung fetch\n\n`apps/worker/src/mailbox-sync-cron.ts` guards re-entrancy with\n`busy = true / busy = false`. With the hung fetch, the await\nnever resolved → `busy` never cleared → every subsequent tick\nno-op'd. The row sat at the `epoch` claim sentinel forever.\n\nThis bug self-resolves with Bug 1's fix (the fetch fail-fasts;\nthe busy flag clears in the finally block). But because rows\nthat ALREADY got stuck before this deploy won't auto-heal,\nBug 3's fix is also needed.\n\n## Bug 3 — No recovery for already-stuck rows\n\n`runMailboxSyncClaim` claims rows where `status='active' AND\nlast_synced_at IS NULL`. A row at the `epoch` sentinel doesn't\nmatch → never re-claimed. Operators had to manually run:\n\n  UPDATE mailbox_accounts\n     SET last_synced_at = NULL\n   WHERE id = '<account-id>';\n\nFix: every call to `runMailboxSyncClaim` first runs a recovery\npass that reverts rows where `last_synced_at = 'epoch'` AND\n`updated_at < now() - INTERVAL '15 minutes'` (configurable via\n`stuckClaimTimeoutMs`). The 15-minute threshold is well past\nthe new 30-second fetch timeout — anything stuck longer is\ngenuinely a worker crash mid-bootstrap, NOT an in-flight call.\n\nThe recovery UPDATE writes a `last_error` of `recovered from\nstuck epoch sentinel — bootstrap never completed` so support\ntooling can grep for it.\n\n`runMailboxSyncClaim` result gains `recoveredStuckClaims: number`\nand the cron's tick log surfaces it at error level — non-zero\nmeans a prior tick died mid-bootstrap, worth an alert pattern.\n\n## Operator self-heal\n\nAfter this deploy, any existing stuck row clears itself within\n**45 seconds + 30 seconds** (one cron interval + the new\nprovider timeout). The full sequence:\n\n1. The 15-min `updated_at` threshold has been past for the row\n   since the original hang — it qualifies for recovery\n   immediately.\n2. Next cron tick (within 30 s) runs recovery → row's\n   `last_synced_at` → NULL.\n3. Same tick's main claim pass picks up the now-NULL row, sets\n   the epoch sentinel, calls `bootstrap()`.\n4. Bootstrap completes (or fails fast within 30 s).\n5. Inbox populates.\n\nNo psql intervention needed.\n\n## Telemetry hint\n\nThe worker now emits at `error` level (so alert pipelines can\ncatch it):\n\n  mailbox-sync-cron: recovered stuck epoch sentinels\n    (prior tick died mid-bootstrap)\n\nIf you see this log line firing repeatedly, the underlying\nprovider call IS still hanging despite the timeout — escalate\nto a network egress check (worker → googleapis.com /\ngraph.microsoft.com).\n\n## Tests\n\n- `modules/mailbox/src/jobs/sync-claim.test.ts` — 3 new cases:\n  - reverts rows stuck at epoch beyond the threshold; main\n    claim picks them up same tick\n  - leaves rows alone within the threshold (in-flight, not\n    stuck)\n  - writes the recovery reason to `last_error` for support grep\n\n- `packages/mailbox-sync/src/providers/gmail/client-timeout.test.ts`\n  — verifies the per-request timeout aborts a hanging fetch\n  and surfaces as `ProviderError('network')`; the test passes\n  in 60 ms (would have hung forever pre-fix).\n\n309 mailbox-module + 157 mailbox-sync tests pass.\n\n## Reference\n\n- Gmail client: [packages/mailbox-sync/src/providers/gmail/client.ts](../../packages/mailbox-sync/src/providers/gmail/client.ts)\n- Graph client: [packages/mailbox-sync/src/providers/graph/client.ts](../../packages/mailbox-sync/src/providers/graph/client.ts)\n- Sync claim: [modules/mailbox/src/jobs/sync-claim.ts](../../modules/mailbox/src/jobs/sync-claim.ts) (`STUCK_CLAIM_TIMEOUT_MS`, recovery UPDATE)\n- Cron: [apps/worker/src/mailbox-sync-cron.ts](../../apps/worker/src/mailbox-sync-cron.ts) (`recoveredStuckClaims` log)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T20:38:06.765Z","updatedAt":"2026-06-16T20:38:06.765Z"},{"id":"cf2a5eb4-c320-4ee2-a15b-e2bf37195e8d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-raw-mime-archiver-storage-action","type":"changed","scope":"email","summary":"Raw RFC 5322 inbound archives now write through storage.object.put — gets the structured storage_objects row, refcount, AV scan, and populates raw_mime_object_id alongside the legacy text key.","body":"Phase 2 commit 3 of the email + mailbox → unified storage\nmigration. Migrates the `MimeArchiver` injection seam from a\ndirect `createStorageClient().putObject(...)` call to the action\nregistry's `storage.object.put` so raw MIME archives behave the\nsame as every other storage producer: AV scan, refcount, dedup,\naudit log.\n\n## What changed\n\n`modules/email/src/jobs/archive-mime.ts` — the `MimeArchiver`\ncallable now returns `{ storageKey: string; objectId: string | null }`\ninstead of just `string`. Both fields ride together so callers can\npopulate BOTH the legacy `raw_mime_object_key` text column (read\nby the admin `/api/email/inbound/<id>/raw` proxy that hasn't\nmigrated yet) AND the new `raw_mime_object_id` FK from Phase 2\ncommit 1.\n\nComposition roots that wire the archiver:\n\n- `apps/web/src/server/email-mime-archiver.ts` — calls\n  `getAction('storage.object.put') + invoke()` with\n  `purpose: 'email_raw_mime'`, `ownerKind: 'module'`,\n  `ownerModule: 'email'`. Builds a system context per call from\n  a DB handle established at boot. Throws if the storage action\n  isn't registered — the boot must import\n  `@helios/storage-module/actions` first.\n\n- `apps/worker/src/index.ts` — mirror of the same change. The\n  worker still has a direct `createStorageClient` around for the\n  GDPR data-export builder (large bundles + streaming uploads\n  that don't fit the action surface yet); only the email archiver\n  was migrated.\n\nCallers of `archiveInboundMime`:\n\n- `apps/web/src/server/email-webhooks.ts` — pass BOTH\n  `rawMimeObjectKey` and `rawMimeObjectId` to\n  `email.inbound.receive`.\n- `modules/email/src/jobs/poll-inbound.ts` — same.\n\n## Idempotency\n\nPer-call idempotency key shape:\n\n  email.raw-mime.<providerId>.<messageId>\n\nWebhook retries deliver the same RFC 5322 Message-ID → same\nidempotencyKey → same row + same `objectId` (sha256 dedup on top\ncatches genuine byte-duplicates regardless of message id).\n\n## Test coverage\n\n`modules/storage/src/lib/email-raw-mime-archiver.integration.test.ts`\n(5 PGlite cases via the real `archiveInboundMime` helper):\n\n- writes a `storage_objects` row with `purpose='email_raw_mime'`,\n  `content_type='message/rfc822'`, `owner_module='email'`\n- returns both `storageKey` and `objectId`\n- idempotent — same Message-ID produces the same objectId; one\n  storage_objects row across two calls\n- returns undefined when archiver is not wired\n- returns undefined when rawMime is undefined\n- swallows archiver throws and logs without failing the inbound\n  dispatch\n\n110 storage integration + 278 email module tests pass.\n\n## Operator notes\n\nAfter deploy, the `email_messages_inbound.raw_mime_object_id`\ncolumn will populate on new inbound rows. The legacy\n`raw_mime_object_key` column ALSO populates during the cutover\nwindow so the existing admin `/api/email/inbound/<id>/raw` reader\nkeeps working. A follow-up migrates the reader to prefer the FK\n+ drops the legacy column.\n\nIf the boot log shows:\n\n  email mime archiver wired to storage.object.put (Phase 2 commit 3)\n\n…you're good. If you instead see:\n\n  storage.object.put is not registered — worker boot must\n  import @helios/storage-module/actions before setMimeArchiver()\n\n…the storage module's actions index isn't loaded. Check the\nworker/web boot order; `@helios/storage-module/actions` must be\nimported BEFORE the archiver-wiring block runs.\n\n## Reference\n\n- Web wiring: [apps/web/src/server/email-mime-archiver.ts](../../apps/web/src/server/email-mime-archiver.ts)\n- Worker wiring: [apps/worker/src/index.ts](../../apps/worker/src/index.ts)\n- Webhook caller: [apps/web/src/server/email-webhooks.ts](../../apps/web/src/server/email-webhooks.ts)\n- Poll caller: [modules/email/src/jobs/poll-inbound.ts](../../modules/email/src/jobs/poll-inbound.ts)\n- Helper: [modules/email/src/jobs/archive-mime.ts](../../modules/email/src/jobs/archive-mime.ts) (`MimeArchiveResult` type)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T20:38:06.783Z","updatedAt":"2026-06-16T20:38:06.783Z"},{"id":"b6614dda-ee47-4874-94bc-87fb802ee919","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-inbound-raw-fk-dual-read","type":"changed","scope":"email","summary":"Admin /api/email/inbound/<id>/raw downloader prefers the new rawMimeObjectId FK + falls back to the legacy text key during the cutover window.","body":"Phase 4 commit 1 of the email + mailbox → unified storage\nmigration. First of the cleanup commits that retire the legacy\n`email_messages_inbound.raw_mime_object_key` text column in\nfavour of the `raw_mime_object_id` FK shipped in Phase 2\ncommit 1.\n\n## What changed\n\n`apps/web/src/server/email-inbound-download.ts` — the admin\n`/api/email/inbound/<id>/raw` route now reads BOTH columns and\nprefers the FK:\n\n1. If `rawMimeObjectId` is set, invoke\n   `getAction('storage.object.get_url')` with `ttlSeconds: 600`\n   and `downloadAs: <message-id>.eml`. Refcount + audit +\n   access-registry treatment runs uniformly with every other\n   storage producer.\n\n2. If the FK is null OR the action call fails, fall back to the\n   legacy direct-driver `presignDownload({ key })` against\n   `rawMimeObjectKey`. Logged at error level so persistent\n   fallbacks surface during the cutover.\n\n3. If both are null → 404 with `raw_not_archived` (same as\n   today).\n\n## Why dual-read\n\nPhase 2 commit 3 (`696aa4a8`) flipped the archiver to write BOTH\ncolumns going forward. New rows have both populated; old rows\nhave only the legacy key. The dual-read keeps admin downloads\nworking through the cutover. A future migration (Phase 4 commit\n2+) backfills the FK on historical rows; once `raw_mime_object_id\nIS NULL` becomes empty across the table, Phase 4 commit 3 drops\nthe legacy text column.\n\n## Tests\n\n`modules/storage/src/lib/email-inbound-raw-fk-dual-read.integration.test.ts`\n(4 PGlite cases through the real storage action):\n\n- FK only → prefers FK path; URL contains the presigned marker\n- legacy-key only → falls back to legacy path\n- BOTH set (cutover-window rows) → still prefers FK\n- BOTH null → source=none, handler returns 404\n\n145 storage integration tests pass.\n\n## Operator notes\n\nAfter deploy, admin downloads from inbound messages received\npost-Phase-2-commit-3 (i.e. since `696aa4a8` rolled out) take\nthe FK path. Older messages still serve via the legacy path —\nno admin-visible regression. Logs at error level for any\n`get_url returned non-ok; falling back` line indicate either:\n\n- The action layer isn't wired (`@helios/storage-module/actions`\n  not imported at boot)\n- The storage object underneath was soft-deleted while a stale\n  `rawMimeObjectId` FK still points at it (possible if the\n  reaper raced ahead — unlikely with the standard retention\n  window)\n\n## What's next\n\n- Commit 2: backfill cron that walks legacy `rawMimeObjectKey`\n  rows where `rawMimeObjectId IS NULL`, looks up the matching\n  `storage_objects` row by `storage_key`, populates the FK.\n- Commit 3: migration that drops the legacy column once\n  backfill catches up.\n- Commit 4: Biome lint rule forbidding `@helios/storage/*`\n  imports outside `packages/storage/**` and `modules/storage/**`.\n\n## Reference\n\n- Handler: [apps/web/src/server/email-inbound-download.ts](../../apps/web/src/server/email-inbound-download.ts)\n- Archiver (Phase 2 commit 3): `696aa4a8`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T20:27:41.669Z","updatedAt":"2026-06-19T20:27:41.669Z"},{"id":"31e95eb6-20e0-49ef-8243-3d6713e5a37e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-attachment-cache-read","type":"added","scope":"mailbox","summary":"Cached mailbox attachments serve from storage via 302 redirect to a 300s presigned URL — no provider round-trip on the hot path.","body":"Phase 3 commit 3 of the email + mailbox → unified storage\nmigration. Closes the lazy-mirror loop opened in Phase 3 commit\n2: the proxy now checks the `mailbox_attachment_cache` table\nBEFORE calling the provider. On hit, the proxy redirects the\nbrowser directly to a short-TTL presigned storage URL — the\nprovider is never touched.\n\n## What changed\n\n`apps/web/src/server/mailbox-attachment.ts` — new\n`tryServeFromCache` helper, invoked BEFORE the provider fetch.\n\nFlow on cache hit:\n\n1. Look up the row by `(account_id, providerMessageId,\n   providerPartId)`.\n2. Invoke `storage.object.get_url` for the cached `objectId`\n   with `ttlSeconds: 300` (5 minutes — enough to recover from\n   a flaky network; short enough that the URL can't be\n   meaningfully shared).\n3. Bump `last_accessed_at` to `now()` so the future eviction\n   cron (Phase 3 commit 5) keeps hot rows.\n4. 302-redirect the browser to the presigned URL.\n\nFlow on cache miss / cache error:\n\n`tryServeFromCache` returns false and the existing provider\nfetch path runs unchanged. A storage outage degrades to \"every\ndownload hits Gmail\" — the pre-Phase-3 behaviour.\n\nThe Q15 ownership checks at the top of the handler (mailbox\nowner + impersonation fence for personal scope) STILL run\nbefore any cache lookup. The cache is a performance layer; the\nauthorisation rules are unchanged.\n\n## HEAD requests\n\nCache-hit HEADs short-circuit with 200 + `cache-control:\nprivate, no-store`. No bytes, no redirect — just headers.\n\n## System context shape\n\nThe `storage.object.get_url` invocation uses a system context\nconstructed with `actorId = account.userId`. That makes the\nstorage action's access-registry policy run against the mailbox\nowner's identity — which is the requesting user (the proxy\nalready validated `account.userId === ctx.actor.id` above).\nThis shape is critical for the Q15 fence on sentinel-org\nrows: the storage row's `owner_user_id` must equal the actor\nto pass, and we're now actor=owner.\n\n## TTL choice\n\n300 seconds:\n\n- Long enough to recover from a transient retry (browser\n  re-fetches the URL after a network blip)\n- Too short for screen-sharing or pasting into a Slack\n  message: by the time someone clicks the link, it's gone\n\nThis matches the conservative end of the OWASP recommendations\nfor resource-link TTLs.\n\n## Tests\n\n`modules/storage/src/lib/mailbox-attachment-read.integration.test.ts`\n(4 PGlite cases exercising the same lookup + presign + bump path\nthe proxy uses):\n\n- cache hit → presigned URL + `last_accessed_at` bumped\n- cache miss → returns hit=false; the proxy falls through\n- personal-scope cache hit honours sentinel orgId + owner read\n- per-attachment uniqueness — different parts in the same\n  message both cache\n\n131 storage integration tests pass.\n\n## What's next in Phase 3\n\n- Commit 4: explicit Q15 fence test lock at the access registry\n  — sentinel-org rows reject non-owner reads even with\n  `platform:storage:usage:read`. The behaviour is already shipped\n  (Phase 1 commit 5); commit 4 is the lock-it-with-tests pass\n  for sentinel-specific shapes.\n- Commit 5: cache eviction on mailbox disconnect — soft-delete\n  cache rows + invoke `storage.object.delete` so refcount\n  decrements cleanly.\n\n## Reference\n\n- Proxy: [apps/web/src/server/mailbox-attachment.ts](../../apps/web/src/server/mailbox-attachment.ts) (`tryServeFromCache`)\n- Phase 3 commit 2 (write path): `0e50125e`\n- Phase 3 commit 1 (schema): `31972031`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T20:27:41.934Z","updatedAt":"2026-06-19T20:27:41.934Z"},{"id":"16574e6d-bdfa-4e72-9d4c-3d9c9bbce85b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-cache-eviction-cron","type":"added","scope":"mailbox","summary":"Cached mailbox attachments are evicted within 5 minutes of mailbox disconnect — storage refcount decrements via storage.object.delete, cache rows removed.","body":"Phase 3 commit 5 of the email + mailbox → unified storage\nmigration. Closes Phase 3. When a user disconnects a mailbox\naccount that has cached attachments, the storage rows would\notherwise sit forever (the cache table FK is RESTRICT so\nnothing else cleans them up). The new eviction cron sweeps\nwithin 5 minutes.\n\n## What changed\n\nNew job + cron in two layers:\n\n### `modules/mailbox/src/jobs/cache-eviction.ts` — the job\n\n`runMailboxCacheEvictionOnce({db, batchSize, perAccountCap, logger})`\nsweeps `mailbox_attachment_cache` rows for soft-deleted accounts\n(`mailbox_accounts.deleted_at IS NOT NULL`). For each row:\n\n- Invokes `getAction('storage.object.delete') + invoke()` against\n  the cached `storageObjectId`. The storage action's\n  refcount-aware delete handles either a final-reference delete\n  (refcount drops to 0 → soft-delete with retention) or a\n  shared-bytes decrement (multiple cache rows → still keep the\n  storage_object alive).\n- On success, removes the cache row.\n- On failure, logs structured context + skips the row so a\n  single bad reference doesn't block the rest.\n\nBounded by `batchSize` (5 accounts/tick default) and\n`perAccountCap` (200 cache rows/tick). Iteration is ordered by\n`last_accessed_at ASC` so a future cron tick processes the\noldest entries first — meaning a hot row racing eviction has\nbeen bumped recently + lands LAST in the per-account batch.\n\n### `apps/worker/src/mailbox-cache-eviction-cron.ts` — the runner\n\n5-minute tick interval. Failure escalation: 3 consecutive failures\nlog at error level with `alert:\nmailbox_cache_eviction_persistent_failure` so the alert pipeline\ncan branch. Persistent failure means disconnected mailboxes are\nNOT releasing storage — worth paging operators.\n\n## Why cron, not event handler\n\nThe disconnect action (`mailbox.account.disconnect_own`) doesn't\nemit an event today — the comment in the action says the\nprovider-side watch teardown is decoupled and the watch-renewal\ncron notices the `deleted_at` row instead. The mailbox module's\nboundary contract also forbids `getAction('storage.object.*')`\ncalls from inside the mailbox action layer. Both reasons point\nto a cron-driven sweep.\n\n## Bonus: admin-side hard-delete also gets swept\n\nA future admin-side hard-delete of a mailbox account (e.g. a\ncompliance retention sweep) lands the same `deleted_at IS NOT\nNULL` shape that triggers eviction. No special wiring needed.\n\n## Tests\n\n`modules/storage/src/lib/mailbox-cache-eviction.integration.test.ts`\n(5 PGlite cases through the real eviction job + storage delete\naction):\n\n- soft-deleted account → cache swept, storage objects soft-\n  deleted, cache rows removed\n- live accounts left alone\n- personal-scope uses sentinel orgId for the delete context\n- idempotent: second run on the same state is a no-op\n- honours `batchSize`\n\n141 storage integration + 309 mailbox tests pass.\n\n## Phase 3 status — COMPLETE\n\nAll five Phase 3 commits shipped:\n\n- `31972031` (1) — sentinel org seed + cache table\n- `0e50125e` (2) — lazy-mirror write path\n- `9a1405f8` (3) — cache read path (302 to presigned URL)\n- `cf527b6a` (4) — Q15 fence integration lock\n- this commit (5) — eviction on disconnect\n\nThe mailbox lazy-mirror is functionally complete. The proxy\ncaches on first download (plan-gated), serves from cache on\nsubsequent requests, enforces Q15 owner-only for sentinel-org\nrows, and releases storage within 5 minutes of disconnect.\n\n## Reference\n\n- Job: [modules/mailbox/src/jobs/cache-eviction.ts](../../modules/mailbox/src/jobs/cache-eviction.ts)\n- Cron: [apps/worker/src/mailbox-cache-eviction-cron.ts](../../apps/worker/src/mailbox-cache-eviction-cron.ts)\n- Disconnect action: [modules/mailbox/src/actions/disconnect-own.ts](../../modules/mailbox/src/actions/disconnect-own.ts)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T20:27:42.188Z","updatedAt":"2026-06-19T20:27:42.188Z"},{"id":"f5054ff5-4555-4e9c-94da-71991fd816bd","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-languages-reuse","type":"changed","scope":"i18n","summary":"The SaaS language catalogue now uses the exact same editor as Settings → Languages.","body":"`/saas/i18n/languages` (root, the platform default every workspace inherits)\nwas a separate, narrower rebuild. It now renders the **same** component as a\ntenant's Settings → Languages — full catalogue picker, enable/disable, set\ndefault, RTL handling, translation-coverage card — just pointed at the\nplatform tier. One component, two surfaces, so they can't drift; tenants still\noverride per-org at `/settings/languages`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T22:37:57.836Z","updatedAt":"2026-06-19T22:37:57.836Z"},{"id":"4b0664e9-cdfc-4aee-a960-a2b5abf66849","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"saas-translations-editor","type":"added","scope":"i18n","summary":"SaaS gains a platform translation editor reusing the Settings → Translations grid.","body":"`/saas/i18n/translations` (root) now edits the platform-tier translation\ncatalogue — the values every workspace inherits — using the **same**\ncategory-grouped editor as a tenant's Settings → Translations: browse by\nnamespace, filter by locale/status/search, paginate, and inline-edit each\nstring's value + workflow status. One component, two surfaces (org override\nat `/settings/translations`, platform base here), so they can't drift.\n\nFor this first cut the machine/AI-translate + import/export tools are hidden\non the platform tier (they need per-tier provider/file plumbing) — the core\nbrowse + inline edit + approve flow is fully available. Surfaced in the SaaS\nconsole under Configuration, beside Languages.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T22:37:58.143Z","updatedAt":"2026-06-19T22:37:58.143Z"},{"id":"42644e72-ac49-48dc-b892-ad2243e3aef6","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-mentions-digest-correct-url","type":"fixed","scope":"chat","summary":"Mentions digest email link now lands on `/chat/mentions?kind=all` instead of a stale `?focus=mentions` shape that no longer resolves.","body":"The daily mentions digest email's \"Open mentions inbox\" button linked\nto `${appUrl}/chat?focus=mentions`, an older search-param shape the\nroute stack no longer recognises. Recipients landed on the channels\nlist with an empty surface instead of their mentions inbox.\n\nThe canonical mentions inbox lives at `/chat/mentions` with a `kind`\nsearch param (`all` | `user` | `here` | `channel`); `kind=all` is the\nright landing tab since it shows every mention (matching the in-app\nbell's surface).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T22:37:58.396Z","updatedAt":"2026-06-19T22:37:58.396Z"},{"id":"01024449-dde1-41b0-ac14-ab7d62eaf8a5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"clients-welcome-email-real-support-address","type":"fixed","scope":"clients","summary":"Client welcome email now uses the org's configured support address instead of derived-from-recipient nonsense.","body":"The `clients.client.welcome` subscriber was building the support\naddress as `support@${recipientDomain}` — meaning it inserted *the\ncustomer's own* domain. A new customer at `john@bigcorp.com` was\nasked to reach out to `support@bigcorp.com`; one at a free webmail\ndomain got `support@gmail.com` etc. When the recipient had no\nextractable `@domain`, it fell back to `support@example.com` — a\nliteral example-RFC sentinel address. Both shapes broke the\n\"contact us\" path.\n\nFix: stop overriding the variable in the subscriber's caller-supplied\ntemplate variables. The email module's `resolveBrandingVars` already\nauto-injects `supportEmail` with the proper `org.support_email →\nplatform.support_email → ''` cascade, and the welcome template's\n`{{#supportEmail}}…{{/supportEmail}}` section guard collapses the\n\"Reach out at\" line cleanly when neither org nor platform has a\nreal address configured.\n\nDropped the now-dead `extractDomain` helper — single live caller.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-19T22:37:58.676Z","updatedAt":"2026-06-19T22:37:58.676Z"},{"id":"1be4b0cb-8ebb-4787-9037-6068aeb5b501","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v13-chrome-entry","type":"changed","scope":"web","summary":"App dock — the whole chrome now slides in from its anchored edge on first paint, not just the bottom floating tray. Reduce-motion skips.","body":"Previously only the bottom floating tray had an entry slide-up; the\ntop strip, vertical columns, and bottom-classic strip all just\nappeared. Now every chrome shape slides FROM its anchored edge toward\nthe viewport interior on first paint:\n\n- bottom → slides up 24 px\n- top    → slides down 24 px\n- left   → slides right 24 px\n- right  → slides left 24 px\n\nCombined with the per-tile mount stagger from v12 the dock now lands\nas one coordinated arrival sequence. `prefers-reduced-motion` skips\nthe chrome slide; `autoHide` skips it too (EdgeReveal owns that\ncase).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.267Z","updatedAt":"2026-06-21T02:11:04.267Z"},{"id":"f566c8c6-f2fd-4b09-a69b-eacda6af54ae","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"recruitment-offer-email-storage-object-id","type":"fixed","scope":"recruitment","summary":"Offer-letter emails now pass storageObjectId so the drain attaches the PDF (closes silent drop bug).","body":"Phase 6.4.5 of the unified Storage + Drive plan — closes the\nrecruitment half of the silent drop-attachment bug surfaced by\nthe audit. The fix builds on the schema column landed in Phase\n6.4.3 (commit `88cd0757`).\n\nThe bug: `recruitment.offer.sent` candidate emails were\narriving in inboxes with no PDF attached. Root cause —\nthe subscriber at\n`modules/recruitment/src/jobs/email-on-offer-events.ts:143`\npassed `storageKey: lookup.pdfStorageKey` to\n`email.outbound.send`'s `attachments[]`, and the drain has\nsilently dropped key-only entries since commit `b136443a`\n(email outbound drain shifted to a `storageObjectId`-first\nread path via `email_outbound_attachments` + `storage.get_stream`).\n\nThe fix wires the Phase 6 offer-letter cache bridge's\n`storage_objects` row all the way through:\n\n1. **`offer-pdf-render.tsx`** — `OfferRenderResult` now\n   carries `storageObjectId: string | null`. The handler\n   captures the value returned by `cacheOfferPdf` (already a\n   `Promise<string | null>`) instead of discarding it via\n   fire-and-forget.\n\n2. **`offer-send.ts`** — persists the captured value into the\n   new `recruitment_offers.pdf_storage_object_id` column (Phase\n   6.4.3) atomically with the status flip to `sent`.\n\n3. **`email-on-offer-events.ts`** — `OfferLookup` picks up\n   `pdfStorageObjectId`; the `offer.sent` handler now passes\n   BOTH `storageObjectId` AND legacy `storageKey` to the email\n   action. The drain prefers `storageObjectId` (resolves via\n   `email_outbound_attachments` join → `storage.get_stream`),\n   falling back to `storageKey` only for legacy offers that\n   pre-date the cache bridge (which still drop in v1 —\n   tracked as a Phase 2 backfill).\n\nFor new offer sends going forward, the candidate's offer PDF\narrives in their inbox.\n\nLegacy offers (sent before the cache bridge rolled out in\n`5140d334`) still have NULL `pdf_storage_object_id` and\ncontinue to surface attachments-less emails on resend. A\nbackfill job that walks legacy `recruitment_offers` rows +\nconstructs storage rows from the existing `pdfStorageKey`\nbytes lands separately as part of Phase 2 cleanup.\n\nAll 188 recruitment tests pass; the pre-existing\nimplicit-any-in-tests typecheck noise on `status-sync.test.ts`\nis unrelated to this migration (last touched `ca3e2cac`).\n\nCloses the recruitment half of 6.4.5 from `12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T05:34:45.969Z","updatedAt":"2026-06-20T05:34:45.969Z"},{"id":"8f10ed3e-fca2-4e72-a83e-57b3146c1aa3","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-maturity-operator-control","type":"added","scope":"web","summary":"Operators can set the product's release stage (stable / alpha / beta) and the maturity-banner copy from platform settings.","body":"The `/saas/platform` settings page gained an **App maturity** section: a release\nstage select (`stable` / `alpha` / `beta`), an optional banner-message override,\nand a report-a-problem link. Setting the stage to alpha or beta turns on the\napp-wide maturity banner for every user; setting it back to stable removes it.\nSaves via `platform.app.update` and updates live with no refresh.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T09:28:51.871Z","updatedAt":"2026-06-20T09:28:51.871Z"},{"id":"9c0e5efc-75a8-4e47-b7b8-8ecdd455778e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-maturity-banner","type":"added","scope":"web","summary":"When the platform is in alpha or beta, a top banner warns users they may hit issues and links to report a problem.","body":"The app now shows a maturity banner at the top when `platform_settings.app_status`\nis `alpha` or `beta`: a reassuring, brand-templated notice (\"{{appName}} is in\nbeta — you may run into occasional issues\") with a **Report a problem** link\n(defaults to `/help/contact`, operator-overridable). It's dismissible per user\nkeyed by the stage, so moving from alpha to beta re-surfaces it, and it's a\npolite `role=\"status\"` live region rendered in the existing banner stack (no\nlayout shift). Hidden when stable.\n\n`platform.app.get` / `get_public_branding` now carry `app_status`,\n`app_status_message`, and `app_status_report_url`, and `platform.app.update`\naccepts them (operator UI for setting them follows).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T09:28:51.899Z","updatedAt":"2026-06-20T09:28:51.899Z"},{"id":"202842f8-89e3-4157-b168-feffd5f02ada","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"auth-email-subjects-empty-orgname-guards","type":"fixed","scope":"email","summary":"Auth email subjects + bodies section-guard `{{ orgName }}` so pre-org signups don't render awkward double-spaces.","body":"The `auth.*` email templates (password reset, sign-in OTP, magic link,\nwelcome, new-device alert, data export, account deletion, etc.) used\n`{{ orgName }}` inline in subjects + bodies — e.g.\n`'Reset your {{ orgName }} password'`. When the recipient has no org\ncontext (pre-signup password reset, system-tier user, or a freshly-\nseeded deployment with no platform brand configured), the\n`resolveOrgNameForAuthEmail` cascade returns the empty string. The\ncaller-supplied `orgName: ''` overrides the resolver's auto-injected\nvalue via the dispatcher's `{ ...auto, ...caller }` merge precedence,\nand the subject renders as `'Reset your  password'` (double space) —\nvisible in every email client's inbox preview.\n\nFix: every `{{ orgName }}` in an `auth.*` subject, preheader, and\nbody sentence is now wrapped in `{{#orgName}}…{{/orgName}}` section\nguards so empty values collapse cleanly. Subjects degrade from\n`'Reset your Acme password'` → `'Reset your password'` instead of\n`'Reset your  password'`.\n\nTemplates touched: `auth.signup.welcome`, `auth.password.reset`,\n`auth.password.changed`, `auth.email.verify`, `auth.email.change_confirm`,\n`auth.email_otp.send`, `auth.two_factor.otp`, `auth.session.new_device`,\n`auth.data.export_ready`, `auth.account.deletion_scheduled`,\n`auth.account.deletion_cancelled`, `auth.magic_link.send`.\n\n`auth.account.deletion_completed` was already correctly guarded from\nthe earlier 2026-05 white-label sweep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T09:28:52.200Z","updatedAt":"2026-06-20T09:28:52.200Z"},{"id":"e81de93a-f75b-42b9-97bf-b5cec65af91b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2a-hrm-projections","type":"changed","scope":"hrm","summary":"HRM list actions now surface the linked Helios user id alongside each employee row so consumers can resolve live avatars.","body":"Phase 2a of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nPure data-shape change — every HRM action that returned\n`{ employeeId, employeeName, employeeAvatarUrl }` now ALSO\nreturns `employeeUserId` (or `userId` on the same-prefix\nrows). The new field is the linked Helios user id from\n`employees.userId`, nullable when the employee has no user\naccount yet.\n\nThis unblocks Phase 2b (the matching frontend consumer\nmigration) — every HRM list / detail / dashboard surface can\nnow thread the userId into `<UserAvatar userId>` so the\nlive image resolves through the shared cache and the realtime\ninvalidator (Phase 1a) propagates avatar changes to every\nvisible HRM row in one hop.\n\nActions extended (with their associated schemas):\n\n- `hrm.leave.list_requests` → `LeaveRequestRow.employeeUserId`\n- `hrm.time.list_entries` + `hrm.time.get` →\n  `TimeEntryRow.employeeUserId`\n- `hrm.compensation.review_records.list` → `RecordRow.employeeUserId`\n- `hrm.equity.grant.list` + `hrm.equity.grant.get` →\n  `GrantRow.employeeUserId`\n- `hrm.equity.vest.list` → `VestEventRow.employeeUserId`\n- `hrm.equity.exercise.list` → `ExerciseRow.employeeUserId`\n- `hrm.offboarding.list_active` → `userId` per item.\n- `hrm.onboarding.dashboard` → `EmployeeRowZ.userId` +\n  `DocumentRowZ.userId`.\n- `hrm.onboarding_tasks.list_active` → `userId` per item.\n- `hrm.org.hr_contact` → `contact.userId`.\n\nAll existing consumers continue to work — the only change is\na new nullable field on each row. No breaking changes.\n\n401/401 HRM tests green; HRM typecheck clean on all touched\nfiles (pre-existing TS7006 errors in unrelated test fixtures\nare foreign).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T09:28:52.782Z","updatedAt":"2026-06-20T09:28:52.782Z"},{"id":"afa31df7-c6f9-402e-b948-413abc8856dc","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v1","type":"added","scope":"web","summary":"New magnifying app dock with left/right/top/bottom anchoring (Settings → Profile → Appearance).","body":"The app's icon navigation can now render as a macOS-style magnifying dock\nin addition to the classic 64 px rail. A new Appearance card under\nSettings → Profile picks the style (dock vs rail) and the dock's anchor\nedge (left, right, top, bottom). Preferences are personal to the device.\n\nThis is v1 — the dock reuses the existing module tile language (DuotoneIcon,\nunread badges, plan-lock chips, maturity dots) so every chrome behaviour\noperators rely on (chat unread, support open count, beta labels) carries\nover. A follow-up will polish each anchor position with a distinct visual\ntreatment (proper macOS-style glass tray for the bottom dock, etc.).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:40.649Z","updatedAt":"2026-06-20T11:28:40.649Z"},{"id":"7d01e016-2845-46c6-8b5f-a68126d6224b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v2-per-position","type":"changed","scope":"web","summary":"App dock — each anchor now has its own polished visual (macOS-style floating tray on bottom, edge strip on top, peeking column on left/right) with a proper 3-zone layout.","body":"The app dock's four anchor positions each get a distinct, researched\nvisual treatment:\n\n- **Bottom** — proper macOS-style floating glass tray. Rounded corners,\n  generous backdrop blur, raised off the bottom edge (safe-area aware),\n  strong magnification (1.8×) with tile lift on peak. The whole tray\n  reads as the operator's home base, not a wall-to-wall strip.\n- **Top** — Windows-11-style edge strip. 56 px, hairline bottom border,\n  tinted backdrop blur. Subtle magnification (1.35×) so the strip stays\n  stationary — anything bigger would compete with the topbar below it.\n- **Left / Right** — Slack/VSCode-style column with peek-out. Medium\n  magnification (1.4×) along the vertical axis + a 4 px nudge toward\n  the cursor instead of a lift; vertical orientation makes lift feel\n  jittery, peek adds depth without displacing neighbours.\n\nLayout is now an explicit `start | middle | end` three-zone shape on\nevery anchor: identity (org switcher) pins to one edge, the user menu\n+ powered-by pin to the other, and the module tiles grow the middle\nwith proper section spacing — closing the visual-crowding issue from\nthe v1 single-stretch layout. A small visual separator between primary\nand secondary modules mirrors the macOS-dock apps/trash divider.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:40.852Z","updatedAt":"2026-06-20T11:28:40.852Z"},{"id":"7c354c30-0aac-47c2-99b3-d01e48ef05fc","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v3-variants-and-autohide","type":"changed","scope":"web","summary":"App dock gains variant axis (classic / default / special), auto-hide with edge-reveal + pulsing chevron indicator, and the legacy \"rail\" choice now respects the position pref.","body":"Three additions to the app dock pref system:\n\n- **Variant axis** — every dock position now has three visual treatments:\n  - `classic` — uniform tiles, no magnification. Quietest, rail-like.\n  - `default` — polished per-position design (macOS-style tray on bottom,\n    edge strip on top, peeking column on left/right). Recommended.\n  - `special` — stronger magnification + larger lift + soft accent halo\n    around the active tile. Expressive.\n\n- **Auto-hide** — opt-in. When on, the dock slides out of view when the\n  cursor leaves and slides back in on edge hover. A pulsing chevron sits\n  on the anchored edge as a discoverability + click affordance for the\n  hidden state. Matches the macOS auto-hidden dock UX.\n\n- **Rail respects position** — picking the legacy `rail` style + a\n  non-left position used to silently render at the left edge. Rail is\n  now an alias for `dock + classic variant`, so the position pref takes\n  effect uniformly.\n\nPick everything from Settings → Profile → Appearance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:41.114Z","updatedAt":"2026-06-20T11:28:41.114Z"},{"id":"3201f2b1-2bb9-4bee-a2be-05dc653d8301","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2b-hrm-consumers","type":"changed","scope":"hrm","summary":"HRM list / detail surfaces now resolve employee avatars via <UserAvatar userId>, so the live image appears wherever the employee has a linked user account.","body":"Phase 2b of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nBuilds on Phase 2a (which surfaced `employeeUserId` / `userId`\non every HRM row projection). The matching frontend consumers\nnow thread that id through to `<UserAvatar userId>`, so the\nshared avatar cache + realtime invalidator (Phase 1a) lights\nup every HRM surface:\n\n- [routes/hrm/leave.tsx](../../apps/web/src/routes/hrm/leave.tsx)\n  — leave request DataTable + employee compact row.\n- [routes/hrm/time.tsx](../../apps/web/src/routes/hrm/time.tsx)\n  — entries DataTable, live-tracking pill, focused-employee\n  card, detail drawer header, weekly-timesheet leftmost column.\n- [routes/hrm/onboarding.tsx](../../apps/web/src/routes/hrm/onboarding.tsx)\n  — awaiting-send list, active-onboarding list, document list.\n- [routes/hrm/offboarding.index.tsx](../../apps/web/src/routes/hrm/offboarding.index.tsx)\n  — active offboardings list.\n- [routes/hrm/offboarding.$employeeId.tsx](../../apps/web/src/routes/hrm/offboarding.%24employeeId.tsx)\n  — detail header avatar.\n- [routes/settings/hrm.comp-review.tsx](../../apps/web/src/routes/settings/hrm.comp-review.tsx)\n  — review-record cards.\n- [routes/settings/hrm.equity.tsx](../../apps/web/src/routes/settings/hrm.equity.tsx)\n  — grant cards.\n\nThe change is purely additive — every site keeps the existing\n`employeeAvatarUrl` value as `imageHint` so the cached snapshot\nstill renders during the initial paint while the batch hook\nresolves the live image. Initials remain the final fallback\nwhen the employee has no linked user OR the user has no image.\n\nEnd-to-end win: when an employee uploads or updates their\navatar in `/settings/profile`, every HRM surface listing them\n(leave queue, time entries, comp review cards, equity grants,\nonboarding board, offboarding board) re-renders with the new\nimage within one realtime hop — no longer stuck on a stale\n`employees.avatar_url` snapshot.\n\nTypecheck on Phase 2b touched files: zero new errors. The\nlocal `LeaveRequest` / `TimeEntry` / `EmployeeRow` / `ActiveRow`\n/ `CompRecord` / `Grant` / `VestEvent` / `Exercise` types\ngained the matching `employeeUserId` / `userId` field that the\nPhase 2a action projections now return.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:41.384Z","updatedAt":"2026-06-20T11:28:41.384Z"},{"id":"e5448382-73f7-41ca-aa91-c8adb3c0e93d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2b-hrm-consumers-2","type":"changed","scope":"hrm","summary":"HRM directory + dashboard + self profile + joining-pack now resolve user avatars via <UserAvatar userId>.","body":"Continues Phase 2b ([`e94005eb`](https://heliosworks.com/commit/e94005eb)).\nThe HRM employee-detail, directory list, self profile,\njoining-pack hero, and HRM dashboard all now resolve user\navatars through the shared cache:\n\n- [routes/hrm/directory.$employeeId.tsx](../../apps/web/src/routes/hrm/directory.%24employeeId.tsx)\n  — hero card avatar.\n- [routes/hrm/directory.index.tsx](../../apps/web/src/routes/hrm/directory.index.tsx)\n  — employee list DataTable cell.\n- [routes/hrm/me.tsx](../../apps/web/src/routes/hrm/me.tsx)\n  — self profile avatar.\n- [routes/hrm/me_.joining-pack.tsx](../../apps/web/src/routes/hrm/me_.joining-pack.tsx)\n  — joining-pack hero + added `userId` to local `Me` type.\n- [routes/hrm/index.tsx](../../apps/web/src/routes/hrm/index.tsx)\n  — open clocks list, employee spotlight, pending leave list;\n  added `userId` to local `EmployeeRow`,\n  `employeeUserId` to `LeaveRequestRow` + `TimeEntryRow`.\n\nEach call keeps `employeeAvatarUrl` / `avatarUrl` as\n`imageHint`. The `hrm.employee.list` + `hrm.employee.full`\nactions already projected `userId`, so no backend changes\nneeded here.\n\nSkipped this commit (the remaining HRM routes whose row\ntypes don't yet carry `userId` / `employeeUserId`):\n- `routes/hrm/performance.tsx`\n- `routes/hrm/rosters.tsx`\n- `routes/hrm/team.tsx`\n- `routes/hrm/one-on-ones.tsx`\n\nThese fall back to initials, which is the correct fallback\nper the user's \"initials = fallback only\" intent — until the\ncorresponding action projections gain the field they will\nneed a similar Phase 2a-style backend pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:41.864Z","updatedAt":"2026-06-20T11:28:41.864Z"},{"id":"16d32538-5099-4f6e-b6ea-3e05e4f0e0a9","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2c-performance-rosters","type":"changed","scope":"hrm","summary":"HRM performance + rosters + 1:1s now resolve user avatars via <UserAvatar userId>.","body":"Phase 2c of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nCombined backend + frontend pass that closes the remaining\nHRM surfaces from the Phase 2b carve-out.\n\n**Backend (projections):**\n- `hrm.goal.list` → `GoalRow.employeeUserId` from\n  `employees.userId`.\n- `hrm.one_on_one.list` → `OneOnOneRow.employeeUserId` from\n  `employees.userId`.\n- `hrm.roster.get` → `RosterSlotRow.employeeUserId` from\n  `employees.userId`.\n- `hrm.feedback.list` already projected `fromUserId` /\n  `toUserId` — no backend change needed.\n\n**Frontend (consumers):**\n- [routes/hrm/performance.tsx](../../apps/web/src/routes/hrm/performance.tsx)\n  — goal row + feedback row avatars (feedback uses\n  `f.fromUserId`).\n- [routes/hrm/one-on-ones.tsx](../../apps/web/src/routes/hrm/one-on-ones.tsx)\n  — meeting row avatar; local `OneOnOneRow` type gained the\n  new field.\n- [routes/hrm/rosters.tsx](../../apps/web/src/routes/hrm/rosters.tsx)\n  — roster employee row + hover-popover; local `EmployeeOpt`\n  type already received `userId` via `hrm.employee.list`.\n\n401/401 HRM tests still green; web typecheck clean on touched\nfiles.\n\nEnd-to-end: a goal/feedback/1:1/roster row now picks up the\nemployee's live avatar from the shared cache and re-renders\nin one realtime hop when the user updates their `/settings/profile`\navatar.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:41.917Z","updatedAt":"2026-06-20T11:28:41.917Z"},{"id":"c170617e-fc6b-43c9-aabe-3ec591dc0c61","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2b-hrm-team","type":"changed","scope":"hrm","summary":"HRM team-dashboard direct-reports list now resolves user avatars via <UserAvatar userId>.","body":"Tail-end of Phase 2b. The HRM team-dashboard\n([routes/hrm/team.tsx](../../apps/web/src/routes/hrm/team.tsx))\ndirect-reports grid now renders user avatars through the\nshared cache. Added `userId` to the local `EmployeeRow` type\nsince `hrm.employee.list` already projects it.\n\nRemaining HRM routes still on raw `<Avatar>`:\n- `routes/hrm/performance.tsx` — `hrm.goal.list` /\n  `hrm.feedback.list` rows.\n- `routes/hrm/rosters.tsx` — shift roster rows.\n- `routes/hrm/one-on-ones.tsx` — meeting rows.\n\nEach of those needs the Phase 2a-style backend pass\n(`employeeUserId` projection) before the consumer can migrate.\nTracked in\n[docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md).\nThey continue to render initials, which matches the user's\n\"initials = fallback only\" intent.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-20T11:28:41.930Z","updatedAt":"2026-06-20T11:28:41.930Z"},{"id":"d951b550-4539-4491-8988-70b0ac538422","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"clients-company-avatar","type":"added","scope":"clients","summary":"Company / client records can now have a logo (avatar) you can upload and change.","body":"The client (company) detail header now shows an editable logo: upload or change\nit with the camera button, remove it with the ×, with the kind icon kept as a\nsmall corner badge. `clients.client.get` / `clients.client.list` return the new\n`avatarUrl` and `clients.client.update` accepts it (persisted on the underlying\ncompany row, stored as a `/api/files/...` proxy URL). Completes avatar support\nacross CRM contacts, leads, and companies.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["clients","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:02.253Z","updatedAt":"2026-06-21T02:11:02.253Z"},{"id":"899417b3-82e6-456a-8f4e-b53d6bdb1c8d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"crm-contact-lead-avatar-ui","type":"added","scope":"crm","summary":"Contact and lead detail pages can now upload, change, and remove a profile picture.","body":"The contact and lead record headers gained an inline avatar editor: hover the\navatar and use the camera button to upload a photo (or the × to remove it). The\npicture uploads directly to storage via the standard presigned flow and is saved\non the record, replacing the initials everywhere the avatar shows. The control is\nhidden for users without update permission. Companies follow next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["crm","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:02.742Z","updatedAt":"2026-06-21T02:11:02.742Z"},{"id":"83b51020-12f4-44be-82b6-a19609a9d57b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"crm-deal-avatars","type":"added","scope":"crm","summary":"Deals can now have an optional avatar / cover image, with initials of the deal title as the default.","body":"Deals join contacts, leads, and companies in supporting an avatar. A new\nnullable `avatar_url` column (migration `0306_0307`) is threaded through\n`crm.deal.update` (accepts `avatarUrl`) and `crm.deal.get` / `crm.deal.list`\n(return it). The deal detail header gains the upload/change/remove avatar\ncontrol, and the pipeline board cards show the deal's image (falling back to the\nlinked company/contact initials, then the deal title). Optional throughout —\ninitials remain the default.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["crm","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:02.776Z","updatedAt":"2026-06-21T02:11:02.776Z"},{"id":"11f7c2e3-9139-4815-b69c-481d93454c52","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"crm-entity-avatars-read","type":"added","scope":"crm","summary":"CRM contact, lead, and company get/list responses now include the avatar URL.","body":"The read path now surfaces each entity's avatar: `crm.contact.get`,\n`crm.contact.list`, `crm.lead.get`, `crm.lead.list`, and `crm.company.list`\nreturn the new `avatarUrl` field (the `/api/files/...` proxy URL, or null). This\nlets contact/lead/company lists and detail pages render the uploaded picture\ninstead of initials. Read-path half of CRM entity avatars; the upload UI follows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["crm","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.147Z","updatedAt":"2026-06-21T02:11:04.147Z"},{"id":"f69b3ceb-2fa8-4d2a-9e22-c95131916fa0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v10-tooltip-and-active-polish","type":"changed","scope":"web","summary":"App dock — restored the polished Radix tooltip on each tile (the v9 inline label felt cramped), simplified the active state to fill + running-app dot, and gave the dot a soft breathing pulse.","body":"Two changes after live feedback:\n\n- **Tooltips restored.** The v9 inline-label experiment placed the\n  hover label inside the tile's motion.div for tighter tracking, but\n  the cramped positioning + sparse styling read worse than the\n  original Radix `<Tooltip>`. Reverted to wrapping each tile in\n  `<Tooltip side={…}>` — the Radix portal handles repositioning as\n  the tile magnifies, so the bubble still tracks correctly without\n  needing the inline path.\n- **Active state simplified.** Previously the active tile showed\n  (1) an accent edge bar at the dock's outer edge, (2) a tinted\n  background fill, (3) the running-app dot, and (4) the accent halo\n  (special / liquid variants). Four indicators read as noise.\n  Dropped the edge bar — the fill + dot + halo carry the meaning\n  cleanly, matching the macOS dock convention more closely.\n- **Running-app dot.** Bumped from 3 px to 4 px (1 → 1 in Tailwind\n  rem units), with a 2.4 s opacity loop (0.6 → 1 → 0.6) so it reads\n  as \"alive\" without being attention-grabby. Reduce-motion holds it\n  steady.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:03.024Z","updatedAt":"2026-06-21T02:11:03.024Z"},{"id":"769be237-8507-4d48-adb1-12b75200882d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"crm-entity-avatars-backend","type":"added","scope":"crm","summary":"CRM contacts, leads, and companies can now store an avatar / profile picture (logo for companies).","body":"CRM contacts, leads, and companies gained an `avatarUrl` field — a profile\npicture (or, for a company, a logo). The three update actions\n(`crm.contact.update`, `crm.lead.update`, `crm.company.update`) now accept\n`avatarUrl` (a server-minted `/api/files/...` proxy URL from\n`platform.asset.upload_url`, or `null` to clear). The image bytes route through\nthe unified Storage module's avatar path and serve via the existing public\nfiles-proxy. This is the persistence foundation; the upload UI follows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["crm","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:03.141Z","updatedAt":"2026-06-21T02:11:03.141Z"},{"id":"d526dc33-7632-40e2-b216-8f0d9f447587","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v11-spacing-and-contrast","type":"changed","scope":"web","summary":"App dock — softer active-tile fill (so Settings doesn't look like a chunky gray block), new default layout clusters zones at the centerline (macOS-style), and chrome bg-mix bumped so the dock separates from page content on light themes.","body":"Three polishes after the live screenshot review:\n\n- **Active fill softened**. The active-row tinted gradient ramped from\n  20 % → 8 %; under the neutral accent (Settings module) that read as\n  a chunky gray block. Tightened to 14 % → 4 % so the indicator\n  declares \"this is active\" without competing with the icon glyph.\n- **Default layout: 'center'**. The previous `between` default\n  pushed OrgSwitcher and UserMenu to the far edges of horizontal docks,\n  leaving cavernous gaps. Centering clusters all three zones at the\n  middle, matching the macOS / iPadOS dock at rest. Operators who\n  prefer the spread look can still pick `between` / `evenly` / `stretch`\n  in Settings → Profile → Appearance.\n- **Chrome bg-mix bumped**. Classic 96 → 100 (opaque), default 88 → 92,\n  special 80 → 84. The horizontal top/bottom strips were reading as\n  nearly invisible on light themes where bg-chrome sits close to bg-app\n  in luminosity.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:03.186Z","updatedAt":"2026-06-21T02:11:03.186Z"},{"id":"d22a68e6-2255-4d00-80eb-682794719d06","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"crm-list-row-avatars","type":"changed","scope":"crm","summary":"Contact, lead, and client lists now show each record's avatar instead of a generic glyph.","body":"The contacts and leads tables and the clients list now render each record's\nuploaded avatar in the name cell (falling back to initials, or — for clients —\nthe kind icon, when none is set). The list actions already returned `avatarUrl`;\nthis surfaces it so a picture set on a detail page shows everywhere the record\nis listed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["crm","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:03.222Z","updatedAt":"2026-06-21T02:11:03.222Z"},{"id":"0984c749-000d-4f34-8fe9-8038adabf275","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"crm-search-palette-avatars","type":"changed","scope":"crm","summary":"Global search (⌘K) results show each record's avatar — contact/lead photo, company logo, or the deal's company logo.","body":"`crm.search` now returns an `avatarUrl` per hit (a contact's or lead's photo, a\ncompany's logo, or — for a deal — its company's logo), and the Command Center\nrenders it as the result's leading icon, falling back to initials. So searching\nfor someone in ⌘K shows their face, not a generic glyph.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["crm","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.185Z","updatedAt":"2026-06-21T02:11:04.185Z"},{"id":"0a9c70d6-3359-4d0f-892e-6ded12f3d9aa","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v12-stagger-and-chrome","type":"changed","scope":"web","summary":"App dock — tiles now stagger-mount on first paint, the zone separator is quieter, and the keyboard focus ring tints to the accent.","body":"Three small chrome refinements:\n\n- **Mount stagger.** Each tile fades-up + scales-in (`{ opacity: 0,\n  scale: 0.85 } → { opacity: 1, scale: 1 }`) with a 35 ms per-tile\n  delay starting 40 ms after the dock mounts. Gives the dock a\n  cohesive arrival sequence on first paint instead of the whole row\n  appearing at once. Respects `prefers-reduced-motion` — under that\n  flag the dock just appears.\n- **Quieter zone separator.** The hairline between primary + secondary\n  modules slimmed from 24 px to 20 px and dropped from 60 % opacity\n  on `border-default` to 50 % on `border-faint`. Reads as a quiet\n  separator instead of a visible bar dividing the dock into chunks.\n- **Tinted focus ring.** Keyboard focus on a dock tile now uses a\n  `ring-2` in the accent's ring token rather than a generic OS outline,\n  matching the rest of the chrome's focus language.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.262Z","updatedAt":"2026-06-21T02:11:04.262Z"},{"id":"4c30b430-8692-407e-9a50-3ff661e84743","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-sweep-a-chat","type":"changed","scope":"chat","summary":"Chat surfaces now resolve user avatars via the new <UserAvatar userId> primitive — once-uploaded images appear everywhere.","body":"Sweep A of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nMigrates every chat-module user-identity avatar from the raw\n`<Avatar src/name>` primitive to the identity-aware\n`<UserAvatar userId>` primitive shipped in `0e2769bc`.\n\nUser-visible win: every chat surface now resolves the avatar\nfrom the user's `users.image` field via the shared cache. The\n\"some surfaces show pic, others show initials\" regression the\nuser reported (a chat row would render initials when the query\nthat fed it didn't include `image`, while the topbar showed the\nsame user's pic) is resolved for chat. A user uploading a new\navatar in `/settings/profile` now sees it propagate to every\nmounted chat surface on the tab via the `/me` cache seeding\nwithout any consumer-side wiring.\n\nFiles migrated:\n\n- [message-item.tsx](../../apps/web/src/components/chat/message-item.tsx)\n  — author row, \"seen by\" row, reaction list, thread participant\n  pile.\n- [typing-indicator.tsx](../../apps/web/src/components/chat/typing-indicator.tsx)\n  — typing pile.\n- [user-hover-card.tsx](../../apps/web/src/components/chat/user-hover-card.tsx)\n  — large hovercard avatar.\n- [user-profile-pane.tsx](../../apps/web/src/components/chat/user-profile-pane.tsx)\n  — profile-pane header avatar.\n- [mention-popover.tsx](../../apps/web/src/components/chat/mention-popover.tsx)\n  — `@mention` candidate list (only the `kind === 'user'` branch\n  — entity mentions keep raw `<Avatar>`).\n- [members-popover.tsx](../../apps/web/src/components/chat/members-popover.tsx)\n  — member roster + add-member candidate list.\n- [channel-engagement-popover.tsx](../../apps/web/src/components/chat/channel-engagement-popover.tsx)\n  — top-contributor list.\n- [channel-view.tsx](../../apps/web/src/components/chat/channel-view.tsx)\n  — DM header avatar.\n- [chat-channels-sidebar.tsx](../../apps/web/src/components/chat/chat-channels-sidebar.tsx)\n  — DM sidebar rows.\n- [entity-chat-references.tsx](../../apps/web/src/components/chat/entity-chat-references.tsx)\n  — cross-module reference row.\n- [entity-comment-thread.tsx](../../apps/web/src/components/chat/entity-comment-thread.tsx)\n  — comment-thread message rows.\n- [ephemeral-compose-button.tsx](../../apps/web/src/components/chat/ephemeral-compose-button.tsx)\n  — recipient typeahead row.\n- [message-detail-modal.tsx](../../apps/web/src/components/chat/message-detail-modal.tsx)\n  — main author + reaction-list rows.\n- [new-dm-modal.tsx](../../apps/web/src/components/chat/new-dm-modal.tsx)\n  — user candidate row.\n\nNot migrated (data-shape limitations — separate work):\n\n- `poll-card.tsx` — `voters` payload is `string[]` of names with\n  no userId. Migrating needs the polling action to return\n  `{userId, name}[]` instead.\n- `huddle-incoming-call.tsx` — `IncomingCall` channel snapshot\n  carries `dmCounterpartName` but no `dmCounterpartUserId`.\n  Migrating needs the realtime channel payload to thread the id.\n\nBoth fall back to the existing raw-`<Avatar>` initials behavior,\nunchanged from today.\n\nSweeps B (dashboards / projects / hrm / crm) and C (chrome /\nsettings / audit / misc) are queued as separate commits.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T11:28:50.921Z","updatedAt":"2026-06-22T11:28:50.921Z"},{"id":"1016627f-fc85-4437-81c4-da0341d3ab14","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-benefits-legacy-importer","type":"added","scope":"hrm","summary":"Phase 12.9.2 — hrm_employee_benefits attachment importer + 6.4.X FK migration (6th per-consumer importer).","body":"Phase 12.9.2 of the Unified Storage + Drive plan — sixth\nper-consumer importer. **First of the HRM ancillaries.**\n\n**Bundled with its Phase 6.4.X FK migration** (same atomic\npattern as the clients commit `b31b6c49`): migration +\nimporter ship together so existing rows get their FK\npopulated within ~13 minutes of worker boot.\n\n**Scope honesty — only `hrm_employee_benefits` in this\ncommit, not all 6 ancillaries.** The 12.9.0 audit doc's\n\"6 ancillaries can ship as one bundled commit\" was\noptimistic; the real schema has:\n\n- **3 single-column ancillaries** (Education\n  `credentialUrl`, Visas `attachmentUrl`, Benefits\n  `attachmentUrl`) — each mirrors the established\n  template + needs its own FK migration. Shippable as\n  separate commits.\n- **1 dual-column ancillary** (Certifications has BOTH\n  `credentialUrl` + `attachmentUrl`) — needs 2 FK\n  columns + sweep iterates both per row. Substantially\n  more complex.\n- **1 array-shape ancillary** (Logbook\n  `attachmentUrls text[]`) — needs either an array of\n  FKs or a join table. New template work.\n\nThis commit ships **only the benefits variant** to keep\nper-commit footprint comparable to the previous 5 mirror\ncommits. The other 4 HRM ancillaries are queued as\nseparate follow-ups; each will reuse this commit's shape.\n\n**What lands:**\n\n- `packages/db/drizzle/0292_0293_hrm_employee_benefits_storage_object_id.sql`\n  (new) — adds `attachment_storage_object_id` UUID FK +\n  ON DELETE SET NULL + index. Column name deliberately\n  `attachment_storage_object_id` (not `storage_object_id`)\n  so the other 3 single-col HRM ancillaries can add\n  THEIR FKs alongside without name collision.\n- `packages/db/drizzle/meta/_journal.json` — idx 294\n- `packages/db/src/schema/hrm.ts` — adds\n  `attachmentStorageObjectId` to `employeeBenefits`\n- `modules/hrm/src/jobs/legacy-benefits-importer.ts`\n  (new ~250 LOC) — sweep helper\n- `modules/hrm/src/jobs/legacy-benefits-importer.integration.test.ts`\n  (new — 8 PGlite cases)\n- `modules/hrm/src/jobs/index.ts` — re-export\n- `apps/worker/src/hrm-benefits-legacy-import-cron.ts`\n  (new) — one-shot cron, +13min stagger\n- `apps/worker/src/index.ts` — register cron after\n  clients's +12min\n- `apps/web/src/lib/cron-intervals.ts` — register\n  `hrm-benefits-legacy-import` as one-shot\n\n**Differences from the 5 previous mirror commits:**\n\n- FK column named `attachmentStorageObjectId` (sister\n  ancillaries will use distinct names like\n  `credentialStorageObjectId` to coexist)\n- Legacy column `attachmentUrl` is **nullable** (many\n  benefits have no proof attachment) → importer filters\n  `attachmentUrl IS NOT NULL` (different from\n  payroll/recruitment/HRM-docs/website where the legacy\n  column is NOT NULL)\n- Purpose `hrm_document` (shared with main HRM documents\n  — benefits proof is just another HR doc shape)\n- ownerModule `hrm`\n- cron stagger +13min (after clients's +12min)\n- metadata captures kind + planName\n\n**Tests (8/8 PGlite green, ~19s):**\n\n- Empty result, import + FK + shard, skip-on-null\n  attachmentUrl (benefits-specific), metadata\n  (kind/planName), skip-if-FK-set, idempotent re-run,\n  HEAD null, orgId scope\n\n**Phase 12.9.2 sequence:**\n\n```\n✅ payroll        (1aacac6b)\n✅ recruitment    (09008d28)\n✅ hrm main       (a2a0b260)\n✅ website        (84f443f2)\n✅ clients        (b31b6c49)\n✅ hrm-benefits   (THIS COMMIT — 1st HRM ancillary)\n⏭ hrm-visas       (single col, mirror)\n⏭ hrm-education   (single col, mirror)\n⏭ hrm-certifications (dual col — 2 FKs needed)\n⏭ hrm-logbook     (array col — new template work)\n⏭ payments        (FK migration first)\n⏭ projects (×3)   (FK migrations first)\n⏭ email outbound + inbound (JSONB shape — new template)\n⏭ mailbox          (highest volume — coordinate)\n⏭ support + roadmap (JSONB shape)\n⏭ organizations branding (URL-vs-key classification first)\n```\n\n**6 of ~14+ consumer importers complete.** The remaining\nHRM ancillaries (visas, education) can ship as ~25-min\nmirror commits each.\n\nCloses Phase 12.9.2 (hrm-benefits variant) +\nPhase 6.4.X (hrm-benefits variant) in\n`docs/plans/UNIFIED_STORAGE_AND_DRIVE/12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T21:54:36.691Z","updatedAt":"2026-06-22T21:54:36.691Z"},{"id":"ac1a0709-037e-4947-b92f-7ecb5e22fa8e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v14-centered-horizontal-content","type":"fixed","scope":"web","summary":"App dock — horizontal top + bottom (classic/default) strips wrap their zones in a centered 1200 px container so OrgSwitcher and UserMenu cluster toward the middle on wide screens instead of being pinned to the viewport edges.","body":"The persistent \"OrgSwitcher pinned to far left, UserMenu pinned to far\nright, cavernous gap in the middle\" on wide screens. The fix from v11\n(`layout: 'center'` default) only helps users without a saved\npreference; everyone else still saw the spread.\n\nNow the horizontal strip's chrome stays full-width (so the background\nhairline spans the viewport) but the zones render inside an inner\n`mx-auto max-w-[1200px]` container. The chrome looks like a real top\nnav: content clustered at the middle, edges quiet.\n\nThis applies to classic + default bottom strips and to the top dock.\nThe floating tray (special / glass / liquid) already auto-fits its\ncontent so it didn't have the issue. Vertical columns are unaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["classic-claude"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.401Z","updatedAt":"2026-06-21T02:11:04.401Z"},{"id":"c0e7df7d-cd3a-4967-86d8-12d1edf2a96a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v4-polish","type":"changed","scope":"web","summary":"App dock v4 — items-layout axis (between/evenly/center/stretch), rail style picker restored, bottom dock proportions tightened, accent halo wired for the special variant, smoother magnification spring.","body":"Iterations on the dock control panel + visuals after live testing:\n\n- **Items layout axis** — new pref controlling how the three zones\n  (identity / modules / user menu) lay out along the dock axis:\n  `between` (default — start, middle, end pinned), `evenly` (equal\n  space around each), `center` (all grouped at the middle), `stretch`\n  (each zone flex-grows edge-to-edge).\n- **Rail style picker restored** — the original AppSidebar (legacy 64 px\n  rail) is again pickable from Settings → Profile → Appearance. Rail at\n  position=left renders the original; rail at any other position routes\n  through dock+classic so the position pref still applies.\n- **Bottom dock proportions** — heights tightened (classic 56 px,\n  default 72 px, special 80 px) and the floating tray auto-fits its\n  content instead of being padded to an inflated minHeight.\n- **Special variant accent halo** — the previously-promised soft halo\n  behind the active tile is now actually rendered (radial bloom in the\n  module tint).\n- **Magnification spring** — retuned to a critically-damped feel\n  (k=320 / c=26 / m=0.12). Previous spring overshot on small tiles.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.437Z","updatedAt":"2026-06-21T02:11:04.437Z"},{"id":"6f9a8207-c89e-4318-9d0c-a4053e9ef04a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v5-hover-zone-and-rail-distinction","type":"fixed","scope":"web","summary":"App dock — auto-hide reveals on hover anywhere along the anchored edge (not just the chevron icon), and the Rail style now looks distinctly different from Dock at every position.","body":"Two regressions fixed:\n\n- **Hover reveal area** — auto-hide previously only revealed the dock on\n  a precise hover over the small chevron icon. The hit-zone is now a\n  36 px band running the full length of the anchored edge so any cursor\n  approach near the edge reveals. The hit-zone unmounts once revealed\n  so it doesn't intercept clicks on the dock body.\n\n- **Rail vs Dock distinction at non-left positions** — at left, Rail and\n  Dock visibly differ (Rail renders the legacy AppSidebar component).\n  At other positions both were rendering AppDock with only a tile-size\n  delta that was too subtle to read as a real style change. Classic\n  variant tiles are now enlarged (44 px) and grouped wider (gap 6 px)\n  so the rail style reads as a \"rail\" at every edge — distinct from\n  the slim dock chromes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.471Z","updatedAt":"2026-06-21T02:11:04.471Z"},{"id":"1f1166de-41f7-46c1-9a88-5c219d686983","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v6-1-authentic-glass-liquid","type":"changed","scope":"web","summary":"App dock — Glass + Liquid variants reworked to actually look like Android Material You frosted glass and Apple Liquid Glass respectively.","body":"The v6 Glass + Liquid variants were too generic — both ended up reading\nas \"default with more blur.\" Reworked from the ground up around the\nreal reference materials:\n\n- **Glass** — Android Material You frosted glass. Higher background\n  opacity (78 %) + an SVG fractal-noise grain overlay tinted to a pale\n  luminance with mix-blend-mode `overlay`. The grain reads as \"etched\n  texture\" rather than polished glass.\n- **Liquid** — Apple Liquid Glass (iOS 26 / macOS 14+). Low opacity\n  (48 %) so content behind shows through, strong blur (32 px) +\n  saturate boost (180 %), iridescent specular rim, a continuous\n  chromatic conic shimmer (accent + AI hue) drifting across the\n  surface with a counter-rotating white highlight ring, and a subtle\n  bottom inner-shadow for a sense of concave depth. Respects\n  `prefers-reduced-motion` — both rotations freeze when the user has\n  reduce-motion on.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T02:11:04.507Z","updatedAt":"2026-06-21T02:11:04.507Z"},{"id":"17c74688-5cd0-4068-b0b6-3bde0a3e6056","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v7-popout-magnification","type":"changed","scope":"web","summary":"App dock — magnification now pops icons OUT of the dock (above the tray, like macOS) instead of growing the whole dock; spring + distance lifted from the React Bits reference.","body":"The previous magnification grew the entire dock chrome as tiles got\nbigger, because each tile's auto-fit dimensions inflated the parent\nflex container. macOS behaves the opposite way: the dock stays at a\nfixed height; tiles grow OUT of it, extending above the tray edge.\n\nFixes:\n\n- **Fixed panel dimensions**. Floating tray height is now `tileBase +\n  py` regardless of magnification; horizontal strips + vertical columns\n  also hold their dimension. Tiles overflow visibly through the\n  dock's chrome edge.\n- **Edge-anchored tiles**. `dockAlign` maps each position to the right\n  flex `align-items` so tiles anchor to the dock's chrome edge —\n  `items-end` for bottom (grow UP), `items-start` for top (grow DOWN),\n  `items-start` for left (grow RIGHT into content), `items-end` for\n  right (grow LEFT into content).\n- **Reference spring**. Adopted `{ mass: 0.1, stiffness: 150, damping:\n  12 }` from the React Bits dock — looser + lighter than the previous\n  critically-damped tuning so the pop-out has a touch of squish.\n- **Stable distance calc**. Uses each tile's static base centre\n  (`rect.x + tileBase/2`) rather than its current centre. As\n  neighbours magnify and shift this tile's `rect.x`, the curve stays\n  smooth instead of feeding back into itself.\n- **No more perpendicular peek**. The pop-out IS the effect — `peekPx`\n  is zero across all variants. macOS doesn't lift, it grows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:13.357Z","updatedAt":"2026-06-21T12:33:13.357Z"},{"id":"c6a2d744-e3ab-4fe3-9847-ef94218c5f8f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v6-glass-liquid","type":"added","scope":"web","summary":"App dock — two new variants (Glass, Liquid) layered on top of classic/default/special, with iridescent rim + drifting sheen.","body":"Five dock variants now ship instead of three:\n\n- **Classic** — flat panel, no glass, no magnification. Reads as a rail.\n- **Default** — light backdrop blur + medium magnification. Polished\n  baseline.\n- **Special** — medium blur + larger magnification + soft accent halo\n  behind the active tile.\n- **Glass** — heavy frosted backdrop blur + saturated mix + iridescent\n  highlight rim. Glassy, like iPadOS / macOS 14+ docks.\n- **Liquid** — `glass` plus an animated drifting accent gradient that\n  drifts across the surface. Apple's \"Liquid Glass\" aesthetic — one\n  slow motion loop, respects `prefers-reduced-motion`.\n\nThe chrome material (blur intensity, background opacity, rim weight,\nshimmer, drop shadow) is centralised in a `CHROME_CONFIG` map keyed by\nvariant. Each chrome branch (bottom strip, floating tray, top strip,\nvertical column) reads from it via shared helpers — so adding a sixth\nmaterial in the future is a single map row.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:13.358Z","updatedAt":"2026-06-21T12:33:13.358Z"},{"id":"a61e11f5-f6c6-49df-a391-4ba1dcc5cdb7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v9-inline-label","type":"changed","scope":"web","summary":"App dock — macOS-style hover label embedded in the tile (tracks magnification naturally), border-radius now scales with the tile, reduced-motion fully skips magnification.","body":"Three small polishes that round out the macOS-dock feel:\n\n- **Inline hover label** — the old Radix Tooltip on each tile lived in a\n  portal and recalculated its position whenever the tile resized, which\n  made it lag the magnification. Replaced with an `AnimatePresence`-\n  driven label rendered as a sibling of the icon inside the same\n  motion.div, so it tracks the tile's position natively. `role=\"tooltip\"`\n  preserves screen-reader semantics.\n- **Border-radius scales with tile** — radius is now set as `18 %` so\n  the corner curvature stays in proportion as the tile grows. Small at\n  rest, larger at peak, never reading as \"drawn for the wrong size.\"\n- **Reduced-motion fully respected** — previously the magnification\n  spring just collapsed to a near-instant value, which still produced\n  jittery dimension changes for vestibular-sensitive users. Now `prefers-\n  reduced-motion` short-circuits the size spring entirely and the tile\n  renders at `tileBase` regardless of cursor proximity. Click bounce is\n  also disabled under reduce-motion.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:13.573Z","updatedAt":"2026-06-21T12:33:13.573Z"},{"id":"e2c8452c-6f1b-4dc1-8bf7-f40f7f2cce3c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-v8-icon-grows-and-bounce","type":"changed","scope":"web","summary":"App dock — icon now scales with the tile (real macOS magnification), tiles get a click bounce, and an active running-app dot lands at the dock edge.","body":"Polish pass building on v7's pop-out mechanic:\n\n- **Icon scales with tile**. Previously the tile background grew while\n  the Phosphor glyph stayed fixed-size — magnification looked like a\n  resizing background plate with a stationary icon. The dock tile now\n  renders the icon at `width: 55 %` / `height: 55 %`, so it grows\n  proportionally with the motion-bound tile dimensions. Real macOS\n  magnification.\n- **Click bounce**. Tapping a tile triggers a brief `scale: 0.9` squish\n  via Motion's `whileTap` + a spring release. Mirrors the macOS dock's\n  tactile feedback. Skipped on disabled / locked tiles.\n- **Running-app dot**. A small accent dot lands at the dock edge under\n  the active module, in addition to the existing active edge bar.\n  macOS dock convention.\n- **Cleanup**. Removed the unused `peekPx` config field + spring (always\n  zero since v7) and the no-longer-used `DuotoneIconAuto` helper. Dock\n  tile now renders directly with motion-aware Phosphor glyphs.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:13.604Z","updatedAt":"2026-06-21T12:33:13.604Z"},{"id":"12027611-d316-4fae-b714-79625dd8f8d7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"app-dock-vertical-cluster-fix","type":"fixed","scope":"web","summary":"App dock — vertical columns now cluster OrgSwitcher + modules at the top with UserMenu anchored at the bottom (Slack/VSCode rail pattern), instead of pinning the three zones to viewport top/center/bottom with cavernous gaps between them.","body":"The v14 fix wrapped horizontal strips in a centered max-width\ncontainer, but vertical columns still pinned OrgSwitcher to viewport\ntop + UserMenu to viewport bottom with the modules floating in the\nvertical centre of a `h-screen` column. Big gaps both above and\nbelow the modules cluster.\n\nVertical columns now use the Slack / VSCode / Linear rail pattern:\n\n- middle zone is never flex-1 (only horizontal docks honour the\n  `between` / `stretch` grow behaviour now)\n- `mt-auto` on the endZone consumes spare main-axis space so the\n  bottom zone (PoweredBy + UserMenu) hugs the column's bottom edge\n- OrgSwitcher + module tiles stay top-aligned, packed tightly with\n  the configured `tileGap`\n\nHorizontal docks are unchanged (v14 max-w container already addresses\nthe same problem in the other axis).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:13.906Z","updatedAt":"2026-06-21T12:33:13.906Z"},{"id":"90a72e9e-d043-41b1-9b77-8b163758827a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2d-payroll","type":"changed","scope":"payroll","summary":"Payroll list + detail surfaces (payslips, runs, group members, recurring, garnishments, reimbursements) now resolve user avatars via <UserAvatar userId>.","body":"Phase 2d of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nCombined backend + frontend pass that lights up every payroll\nemployee surface.\n\n**Backend (projections):**\n- `payroll.payslip.list` + `payroll.payslip.get` → adds\n  `employeeUserId` on `PayslipRow` + `PayslipDetail`.\n- `payroll.run.get` → `payslipsPreview[*].employeeUserId`.\n- `payroll.group.list_members` → `PayGroupMemberRow.employeeUserId`.\n- `payroll.recurring.list` + `payroll.garnishment.list` +\n  `payroll.reimbursement.list` + `payroll.recurring.off_cycle.list`\n  → shared `RecurringRow` / `GarnishmentRow` / `ReimbursementRow` /\n  `OffCycleRow` schemas all gain `employeeUserId` via the\n  existing employee-id lookup map.\n\n**Frontend (consumers):**\n- [routes/payroll/payslips.index.tsx](../../apps/web/src/routes/payroll/payslips.index.tsx)\n  — payslip list cell.\n- [routes/payroll/payslips.$payslipId.tsx](../../apps/web/src/routes/payroll/payslips.%24payslipId.tsx)\n  — payslip detail hero avatar.\n- [routes/payroll/runs.$runId.tsx](../../apps/web/src/routes/payroll/runs.%24runId.tsx)\n  — payslip preview rows under a run.\n- [routes/payroll/groups.$groupId.tsx](../../apps/web/src/routes/payroll/groups.%24groupId.tsx)\n  — group members table + add-member picker rows.\n- [routes/payroll/recurring.tsx](../../apps/web/src/routes/payroll/recurring.tsx)\n  — recurring earning/deduction list.\n- [routes/payroll/garnishments.tsx](../../apps/web/src/routes/payroll/garnishments.tsx)\n  — garnishment list.\n- [routes/payroll/reimbursements.tsx](../../apps/web/src/routes/payroll/reimbursements.tsx)\n  — reimbursement list.\n\nEach local frontend type gained the matching `employeeUserId`\nfield (or `userId` on row-shaped types).\n\n47/47 payroll tests green. Web typecheck on payroll routes\nclean except 3 pre-existing errors (payslips.index.tsx spread,\nruns.index.tsx spread, settings.tsx Router strict typing) —\nall foreign to this commit.\n\nEnd-to-end: a payroll payslip / run / group / recurring /\ngarnishment / reimbursement row now picks up the live employee\navatar from the shared cache and re-renders within one realtime\nhop when the user updates their profile avatar.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:13.906Z","updatedAt":"2026-06-21T12:33:13.906Z"},{"id":"9d164dc8-edde-40ce-9f5a-25b23be88189","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-phase-2e-recruitment-staff","type":"changed","scope":"recruitment","summary":"Recruitment staff avatars (hiring managers, reporting managers, scorecard panelists) now resolve via <UserAvatar userId>.","body":"Phase 2e of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nSurfaces every recruitment-side **Helios-user** avatar through the\nshared cache while explicitly **leaving candidate avatars on the\nraw primitive** — candidates aren't Helios users, so the existing\n`recruitment_candidates.avatarUrl` snapshot remains correct.\n\n**Frontend (consumers — no backend change needed):**\n- [routes/recruitment/jobs.$jobId.tsx](../../apps/web/src/routes/recruitment/jobs.%24jobId.tsx)\n  — hiring-manager + reporting-manager chips in the header.\n- [routes/recruitment/jobs.index.tsx](../../apps/web/src/routes/recruitment/jobs.index.tsx)\n  — hiring-manager cell in the jobs DataTable.\n- [routes/recruitment/applications.$applicationId.tsx](../../apps/web/src/routes/recruitment/applications.%24applicationId.tsx)\n  — scorecard panelist avatar (uses `s.panelistUserId`).\n  The candidate header avatar stays raw `<Avatar>` because\n  `applications.candidate.avatarUrl` is a candidate snapshot.\n\nThe hiring-manager / reporting-manager chips already received\nthe user object from `iam.membership.list`, which carries\n`userId`. No backend extension was required.\n\nEnd-to-end: a recruiter's avatar change in `/settings/profile`\nnow propagates to every recruitment surface where their identity\nis shown (jobs list, job detail, scorecard panel) within one\nrealtime hop.\n\nWhat's intentionally **out of scope** for this sweep:\n- Candidate avatars on `talent.index.tsx`,\n  `talent.$candidateId.tsx`, `applications.index.tsx`, and the\n  candidate header on `applications.$applicationId.tsx` — they\n  represent candidate identities, not Helios users. They stay\n  on the raw `<Avatar>` primitive with the candidate's\n  uploaded `avatarUrl` snapshot.\n- Hired-candidate-as-user: when a candidate goes through\n  `recruitment.application.recruit` and the action creates an\n  HRM employee + linked user, the resulting surfaces (HRM\n  directory, etc.) already resolve through `<UserAvatar>` via\n  the HRM projections shipped in Phase 2a/2b/2c.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-21T12:33:14.153Z","updatedAt":"2026-06-21T12:33:14.153Z"},{"id":"c09d05f0-0ff4-4d26-b08a-0a6c42218ca9","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"avatar-sync-sweep-c-user-menu","type":"changed","scope":"web","summary":"Sidebar + topbar user-menu now renders the uploaded avatar image instead of bare initials.","body":"Sweep C of the user-avatar sync plan\n([docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md)).\nThe chrome's user-menu trigger\n([apps/web/src/components/user-menu.tsx](../../apps/web/src/components/user-menu.tsx))\nhand-rendered initials directly in a styled box — it was the most\nvisible \"initials, even when I have a picture\" surface across the\napp.\n\nIt now resolves `users.image` via `useUserAvatar(me.id)` (seeded\nby the `/me` cache from Phase 1) and paints the image inside the\nexisting rounded tile, with the initials box as the fallback when\nno image is set. The presence dot, dimensions, and dropdown all\nstay intact — purely additive change to one surface.\n\nWhat's intentionally **not** in Sweep C: the remaining ~51 HRM /\nrecruitment / payroll / CRM-rows / dashboard-rows that key off\nemployee/candidate/contact id rather than user id. Migrating\nthem needs upstream query projections to surface `linkedUserId`\non every row first — separate work tracked in\n[docs/plans/USER_AVATAR_SYNC_SPEC.md](../../docs/plans/USER_AVATAR_SYNC_SPEC.md).\n\nCloses the visible-on-every-page leg of the user-reported\n\"some surfaces show pic, others show initials\" issue. The\nspecific surfaces still showing initials after Sweeps A-C are\nthe employee/candidate/contact-list cells, which fall outside\nthe \"this is a Helios user\" semantic anyway.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T11:28:51.570Z","updatedAt":"2026-06-22T11:28:51.570Z"},{"id":"be4b53c8-e422-4976-b7e8-dee8e0c7c698","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-attachment-download-dual-read","type":"changed","scope":"chat","summary":"chat.attachment.download_url prefers storage.object.get_url when the attachment is a post-migration storage_objects row; legacy presign stays for older messages.","body":"Follow-up to the chat attachment upload migration shipped at\n`9f22f989` (Phase 6.1.2). The upload action stamps each new\nattachment with `id = <storage_objects.id>`; the download\nhandler now dual-reads + prefers the action-layer path so the\nupload + download halves are symmetric.\n\n## What changed\n\n`modules/chat/src/actions/attachment-download.ts`:\n\n- After validating channel access, probes `storage_objects` for\n  a row matching `(id = att.id, org_id = ctx.orgId)`. The probe\n  short-circuits when `att.id` isn't a UUID — defensive against\n  unusual legacy data shapes.\n- On hit: invokes `getAction('storage.object.get_url')` with\n  `ttlSeconds: 600` + `downloadAs: att.filename`. The 10-minute\n  TTL matches the legacy contract; the action gets the\n  refcount + AV + audit + access-registry treatment uniformly.\n- On miss (pre-migration message OR row swept) OR action layer\n  unavailable OR action returns non-ok: falls through to the\n  legacy direct-driver `presignDownload({ key })`. Logged at\n  error level when the FK path WAS available but failed so\n  persistent fallbacks surface during the cutover.\n\n## Why dual-read\n\nThe upload migration's `att.id = storage_objects.id` only\napplies to attachments uploaded AFTER `9f22f989` shipped.\nMessages posted before then carry an `att.id` that's a random\nUUID generated by the old action — no matching storage row.\nDropping the legacy presign would 404 those older messages\nforever.\n\nThe dual-read keeps both paths alive until message retention\npurges the pre-migration rows naturally.\n\n## Authorisation unchanged\n\nThe handler's existing channel-access gate runs BEFORE the\nstorage probe. The actor must already pass the `attachmentDownloadPolicy`\ncheck and be either a channel member or in a public/announcement\nchannel. The storage action's own org-isolation + access-registry\ngates run on TOP of that — defence-in-depth.\n\n## Tests\n\n`modules/storage/src/lib/chat-attachment-download-dual-read.integration.test.ts`\n(4 PGlite cases through the real storage action):\n\n- post-migration `att.id` IS a `storage_objects.id` → FK path,\n  presigned URL returned\n- pre-migration `att.id` is a random UUID with no storage row →\n  legacy direct-driver presign path\n- non-UUID `att.id` (defensive) → legacy path\n- cross-org storage row is invisible → falls through to legacy\n  (no cross-tenant leak through the dual-read)\n\n267 chat-module tests pass; 145 storage integration tests pass.\n\n## Operator notes\n\nPersistent error logs from `chat.attachment.download_url: get_url\nreturned non-ok; falling back to legacy key presign` indicate\neither:\n\n- `@helios/storage-module/actions` not imported at boot\n- A `storage_objects` row was soft-deleted while a chat message\n  still references its id (possible if the retention reaper\n  raced ahead — unlikely with the standard window)\n\nThe legacy fall-back means downloads keep working; the log\nline is for triage, not user-visible breakage.\n\n## Producer-migration progress\n\nCounter at `createStorageClient` direct callers down by one\ntoward the goal of eventually shipping the Biome lint rule from\nPhase 4 commit 4. Mailbox sync + storage module internals stay\non the driver (legitimate); the remaining ~17 producer modules\nstill need symmetric upload+download migrations.\n\n## Reference\n\n- Handler: [modules/chat/src/actions/attachment-download.ts](../../modules/chat/src/actions/attachment-download.ts)\n- Upload migration (companion): `9f22f989` Phase 6.1.2\n- Same dual-read pattern shipped for admin /raw: Phase 4 commit 1 (`19b80961`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:19:54.853Z","updatedAt":"2026-06-22T20:19:54.853Z"},{"id":"03dd2b72-422a-45ca-9fdd-2283e3ec692f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"changelog-version-surfaces","type":"added","scope":"web","summary":"The app version is now shown on the changelog (\"what's new\") releases and in the About line, with the maturity stage.","body":"Each release on /help/whats-new now shows its SemVer version (e.g. `v0.10.0`)\nnext to the release tag, and the read API returns it. The user-menu About line\nnow shows the app version (`0.9.0`) alongside the maturity stage (alpha/beta)\nand the build id, so version + stage + build are visible together. The app's\nbaseline version is set to `0.9.0`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:19:54.860Z","updatedAt":"2026-06-22T20:19:54.860Z"},{"id":"8c5d5b99-c9c3-4c81-b175-bacff450bc1b","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"clients-document-download-dual-read","type":"changed","scope":"clients","summary":"clients.document.download_url (operator side) prefers storage.object.get_url when storage_objects has a matching row by storage_key; legacy presign stays for pre-migration documents.","body":"Follow-up to the clients upload migration shipped at `a21cebce`\n(Phase 6.1.1) and the portal-side dual-read shipped at `d790dac2`.\nThe operator-side download handler now mirrors the same probe.\n\n`client_documents` doesn't have a `storage_object_id` FK column\nyet, so the dual-read probes `storage_objects` by `storage_key`\nrather than by id (same shape as the portal action, distinct from\nthe chat/support id-probe pattern).\n\n## What changed\n\n`modules/clients/src/actions/client-documents.ts`\n(`getDocumentDownloadUrl`):\n\n- After the existing engagement / company / org-prefix checks,\n  probes `storage_objects` for a row matching\n  `(storage_key = doc.storageKey, org_id = ctx.orgId, deleted_at IS NULL)`.\n- On hit: invokes `getAction('storage.object.get_url')` with\n  `ttlSeconds: Math.floor(DOWNLOAD_TTL_MS / 1000)` and\n  `downloadAs: doc.name`. Refcount + AV + audit + access-registry\n  treatment now applies to operator-side downloads too.\n- On miss / action unavailable / non-ok: falls through to the\n  legacy direct-driver `presignDownload({ key, filename })`.\n  Persistent fallbacks logged at error level during cutover.\n\n## Why dual-read\n\nSame rationale as the portal side: pre-migration documents have\na populated `storage_key` but no matching `storage_objects` row.\nDropping the legacy presign would 404 those forever. Both paths\nstay alive until a producer backfill aligns prod data and the\n`clientDocuments` schema picks up a `storage_object_id` FK\ncolumn.\n\n## Tests\n\n`modules/storage/src/lib/clients-document-download-dual-read.integration.test.ts`\n(4 PGlite cases through the real `storage.object.get_url`):\n\n- post-migration `storage_key` matches a `storage_objects` row → FK\n- pre-migration `storage_key` with no matching row → legacy\n- soft-deleted row → falls through to legacy (probe excludes)\n- cross-org row → invisible to probe, falls through to legacy\n\n202 clients tests pass.\n\n## Producer-migration progress\n\nAnother `createStorageClient` direct caller migrated. The\ncounter continues to chip toward the eventual Phase 4 commit 4\nBiome lint rule for `@helios/storage` itself (SDK-level\nrestrictions for `@aws-sdk/client-s3` etc. already shipped via\nthe existing `noRestrictedImports` set).\n\n## Reference\n\n- Handler: [modules/clients/src/actions/client-documents.ts](../../modules/clients/src/actions/client-documents.ts) (`getDocumentDownloadUrl`)\n- Portal counterpart: `d790dac2`\n- Pattern precedent: Phase 4 commit 1 (`19b80961`); chat `8f962923`; support `edc43331`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:19:55.383Z","updatedAt":"2026-06-22T20:19:55.383Z"},{"id":"9c1c31c4-e3ba-40ab-8c7d-d5824ac6eb77","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"clients-legacy-document-importer","type":"added","scope":"clients","summary":"Phase 12.9.2 — clients legacy document importer + Phase 6.4.X FK migration (5th per-consumer importer).","body":"Phase 12.9.2 of the Unified Storage + Drive plan — fifth\nper-consumer importer. **Combines two phases in one\ncommit:**\n\n1. **Phase 6.4.X (clients variant)** — schema migration\n   `0291_0292_client_documents_storage_object_id.sql` adds\n   the `storage_object_id` UUID FK column to\n   `client_documents` (the audit doc flagged it as the\n   missing FK companion). Mirror of website_media (6.4.1),\n   recruitment_offers (6.4.3), hrm_employee_documents\n   (6.4.4), payroll_payslips (6.4.X), payments\n   receipt_pdf_storage_object_id (6.4.5).\n\n2. **Phase 12.9.2 (clients variant)** — the importer that\n   walks pre-FK rows and populates the new column. Mirror\n   of the previous 4 importers.\n\n**Why bundled:** the FK column landing without an importer\nto fill it for existing data leaves the column 100% NULL\non every tenant — defeats the whole point of the\nmigration. Shipping them atomically means every existing\nclient_document has its FK populated within ~12 minutes\nof worker boot.\n\n**Differences from the previous 4 mirror variants:**\n\n- New filter: `uploadedAt IS NOT NULL` — `client_documents`\n  has a `uploaded_at` stamp that marks \"the presigned PUT\n  succeeded.\" Rows with NULL uploadedAt are the\n  pending-upload state; importing them would FK at bytes\n  that don't exist at the provider yet. The next sweep\n  catches them once uploadedAt stamps.\n- soft-delete filter (same as website variant)\n- cron stagger: +12min (after website's +11min)\n- metadata captures companyId + kind (clients-specific\n  fields the audit + Drive picker surfaces will key off)\n\n**What lands:**\n\n- `packages/db/drizzle/0291_0292_client_documents_storage_object_id.sql`\n  (new) — schema migration\n- `packages/db/drizzle/meta/_journal.json` — idx 293\n  entry, when=1805587200000 (last+86400000)\n- `packages/db/src/schema/clients.ts` — adds\n  `storageObjectId` column\n- `modules/clients/src/jobs/legacy-client-document-importer.ts`\n  (new ~230 LOC) — sweep helper\n- `modules/clients/src/jobs/legacy-client-document-importer.integration.test.ts`\n  (new — 9 PGlite cases)\n- `modules/clients/src/jobs/index.ts` — re-export\n- `modules/clients/package.json` — adds\n  `@helios/storage-module` runtime dep\n- `apps/worker/src/clients-legacy-import-cron.ts` (new) —\n  one-shot cron, +12min stagger\n- `apps/worker/src/index.ts` — register cron after\n  website's\n- `apps/web/src/lib/cron-intervals.ts` — register\n  `clients-legacy-import` as one-shot in staleness registry\n- `pnpm-lock.yaml` — picks up the new\n  `@helios/storage-module` dep edge\n\n**Tests (9/9 PGlite green, ~17s):**\n\n- Empty result with no candidates\n- Import + FK write-back\n- Metadata marker carries companyId + kind\n- Skip-if-FK-set\n- Skip soft-deleted\n- Skip uploadedAt NULL (pending upload)  ← clients-specific\n- Idempotent re-run\n- HEAD null → missingAtProvider\n- orgId scope filter\n\n**Phase 12.9.2 sequence after this commit:**\n\n```\n✅ payroll        (1aacac6b — template)\n✅ recruitment    (09008d28 — mirror)\n✅ hrm main       (a2a0b260 — mirror)\n✅ website        (84f443f2 — mirror)\n✅ clients        (THIS COMMIT — mirror + FK migration)\n⏭ hrm 6 ancillaries (need shared FK migration first)\n⏭ payments (needs FK migration first)\n⏭ projects 3 sub-tables (need FK migrations)\n⏭ email outbound + inbound (JSONB shape — new template)\n⏭ mailbox (highest volume — coordinate with mailbox specialist)\n⏭ support + roadmap (JSONB shape)\n⏭ organizations branding (URL-vs-key classification first)\n```\n\n**5 of ~14 consumer importers complete.** Template proven\n5x. The \"easy mirror\" candidates are now exhausted —\nevery remaining importer needs either a FK migration\nfirst (payments, hrm ancillaries, projects, clients done\nNOW in this commit) OR a JSONB-iteration shape (email,\nmailbox, chat, support, roadmap, expenses).\n\nCloses Phase 12.9.2 (clients variant) +\nPhase 6.4.X (clients variant) in\n`docs/plans/UNIFIED_STORAGE_AND_DRIVE/12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:19:55.672Z","updatedAt":"2026-06-22T20:19:55.672Z"},{"id":"9ef64ed0-5a89-4369-81ea-43cc2c67342e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-legacy-document-importer","type":"added","scope":"hrm","summary":"Phase 12.9.2 — HRM legacy employee-document importer (3rd per-consumer importer).","body":"Phase 12.9.2 of the Unified Storage + Drive plan — third\nper-consumer importer. Walks `hrm_employee_documents` rows\nwhere `storageObjectId IS NULL` (= pre-Phase-6.4.4\ndocuments; legacy `storage_url` text key but no FK to\n`storage_objects`), HEAD-verifies bytes exist at the\nprovider, calls `storage.object.import` to create the\nunified row + record an `'imported'` meter event, writes\nthe FK back on success.\n\n**Mirror of the established template** (payroll\n`1aacac6b` + recruitment `09008d28`). Differences:\n\n| Aspect | Template | HRM variant |\n|---|---|---|\n| Table | `payroll_payslips` | `hrm_employee_documents` |\n| Legacy column | `pdfUrl` | `storageUrl` (NOT NULL) |\n| FK column | `pdfStorageObjectId` | `storageObjectId` |\n| Purpose | `payslip` / `recruitment_offer` | `hrm_document` |\n| ownerModule | `payroll` / `recruitment` | `hrm` |\n| Idempotency key prefix | `legacy-import.payroll.payslip.<id>` | `legacy-import.hrm.document.<id>` |\n| Cron stagger | +8min / +9min | +10min |\n| Filename build | numeric-ID-suffix | `${title || kind}-legacy${ext}` |\n| Metadata extras | payslip number | kind, title |\n\nThe HRM importer adds a small `guessExtension(mime)` helper\nthat maps the row's `mime_type` column (or HEAD-reported\ncontent-type) → a sensible filename extension. Falls back\nto `.pdf` since the vast majority of HRM documents\n(contracts, NDAs, offer letters, joining letters, policy\ndocs) are PDFs.\n\n**What lands:**\n\n- `modules/hrm/src/jobs/legacy-document-importer.ts`\n  (new ~280 LOC) — sweep helper.\n- `modules/hrm/src/jobs/legacy-document-importer.integration.test.ts`\n  (new — 8 PGlite cases mirroring payroll + recruitment).\n- `modules/hrm/src/jobs/index.ts` — re-export.\n- `apps/worker/src/hrm-legacy-import-cron.ts` (new) —\n  one-shot cron, +10min stagger.\n- `apps/worker/src/index.ts` — register cron after the\n  recruitment one.\n- `apps/web/src/lib/cron-intervals.ts` — register\n  `hrm-legacy-import` as one-shot so the\n  `/saas/health` panel reads \"Done\" not false-stale\n  (this commit + the staleness fix `3a501b5c` compose\n  cleanly).\n\n**Tests (8/8 PGlite green on first run, ~11s):**\n\n- Empty result with no candidates\n- Import + FK write-back + org shard bump\n- Metadata marker presence (kind + title carried)\n- Skip-if-FK-set\n- Idempotent re-run\n- HEAD null → missingAtProvider\n- orgId scope filter\n- Filename built from title (containing \"Joining\",\n  ending in `.pdf`)\n\n**What's NOT in this commit (queued):**\n\n- 6 HRM ancillary tables (passport visas, work permits,\n  birth certificates, professional certifications,\n  licenses, compensation benefit proof uploads) — these\n  carry `attachmentUrl` / `credentialUrl` text columns\n  WITHOUT an FK companion column. They need a Phase\n  6.4.X-style FK migration (one shared migration for the\n  6 tables) BEFORE their importer can ship. Queued as a\n  follow-up.\n\n**Phase 12.9.2 sequence after this commit:**\n\n```\n✅ payroll        (1aacac6b — template)\n✅ recruitment    (09008d28 — mirror)\n✅ hrm main       (THIS COMMIT — 3rd mirror)\n⏭ hrm ancillary 6 tables (need FK migration first)\n⏭ email outbound + inbound\n⏭ mailbox (highest volume — coordinate with mailbox specialist)\n⏭ clients, support, payments, projects, expenses, website, roadmap, organizations branding\n```\n\n**3 of ~14 consumer importers complete.** Template now\nproven 3x across different schemas; remaining commits\nare mechanical with the FK-migration-first wrinkle for\nHRM ancillaries.\n\nCloses Phase 12.9.2 (HRM main variant) in\n`docs/plans/UNIFIED_STORAGE_AND_DRIVE/12_REMAINING_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:06:27.504Z","updatedAt":"2026-06-23T00:06:27.504Z"},{"id":"ccce0ec8-919e-4f4f-868c-9f2e8c14aa71","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-breadcrumb-and-forward-labels","type":"fixed","scope":"chat","summary":"Topbar breadcrumb resolves channel UUIDs under the new space-aware URL shape, and \"Forwarded from …\" no longer spits out the DM slug.","body":"Two surfaces in the chat module were still leaking channel UUIDs into\nuser-visible text:\n\n**Topbar breadcrumb.** When the new space-aware URL shape\n`/chat/<spaceSlug>/<channelId>` landed, the breadcrumb's UUID resolver\nstill only matched the legacy `/chat/<uuid>` shape (it gated on\n`prevSegment === 'chat'`). The new shape's channel-id segment had\n`prevSegment` equal to the space slug, so the resolver returned `null`\nand the segment fell through to the default \"humanize a kebab string\"\nfallback — which capitalised the UUID's hex chunks and rendered as\n`F413a366 7eab 4117 8dc2 Efa49a6541b6`. The resolver now triggers on\nany UUID segment under `/chat/`, regardless of which depth the URL\nshape puts it at. Group DMs get a localized \"Group message\", DMs use\nthe counterpart name, regular channels keep their existing\n`name → slug → \"Untitled channel\"` fallback.\n\n**Forwarded-message preface.** `chat.message_item.ts`'s forward\nmutation pulled the source-channel display via\n`source.name ?? source.slug`. For DMs the name is null and the slug is\nthe deterministic `dm-<uuid>-<uuid>` token, so the forwarded body read\n`Forwarded from #dm-43395d4e-…-82ee87db-…`. The preface is now branched\nby source type:\n\n- **DM source** → `Forwarded from a message with {counterpart}`\n- **Group DM source** → `Forwarded from a group message`\n- **Channel source** → `Forwarded from #<name>` (unchanged)\n\nAll four new strings are translatable; the breadcrumb resolver also\nsets a translated `Untitled channel` fallback for channels missing from\nthe cache (rather than `null`, which silently surfaced the raw segment).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:19:55.123Z","updatedAt":"2026-06-22T20:19:55.123Z"},{"id":"09752d50-4d9b-4834-b904-e68f079a5f15","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"chat-humanized-fallback-labels","type":"changed","scope":"chat","summary":"Chat surfaces no longer expose channel-id hex slices when a channel's name + slug are both missing; group DMs get a localized \"Group message\" label.","body":"Audit pass on every chat surface that renders a channel/DM title. The\npattern `channel.name ?? channel.slug ?? channel.id.slice(0, 8)` was\nin four places (channel header, sidebar row, catch-up inbox row, Cmd+K\npalette). When both name and slug were null — which is the normal state\nfor an as-yet-unnamed group DM, plus rare misconfigurations on regular\nchannels — the UI surfaced an 8-character hex prefix of the channel\nUUID. Users reported it as \"shows the channel id\".\n\nSlugs (`general`, `engineering`, `dm-is-<uuid>-<uuid>`, etc.) are still\nthe secondary fallback and remain unchanged — the user confirmed they're\nfine as-is. The change is purely the **final** fallback when slug is\nalso null:\n\n- Group DMs (`type === 'group_dm'`) get `tt('chat.channel.group_dm',\n  'Group message')` so they read as a conversation, not as a row id.\n- All other types (an unconfigured row) get `tt('chat.channel.untitled',\n  'Untitled channel')`.\n- The Cmd+K palette + the inbox catch-up row + the channel header + the\n  AiThreadPane label all use the same shape so a user sees the same\n  label for the same channel everywhere.\n\nDM rows (`type === 'dm'`) keep the existing\n`dmCounterpartName ?? name ?? tt('Direct message')` fallback (already\nhuman-shaped — this commit doesn't touch it).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:19:55.384Z","updatedAt":"2026-06-22T20:19:55.384Z"},{"id":"f1834f2d-c4ad-4180-84c4-3679882cb0e0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"clients-portal-document-download-dual-read","type":"changed","scope":"clients","summary":"clients.portal.document_download_url prefers storage.object.get_url when storage_objects has a matching row by storage_key; legacy presign stays for pre-migration documents.","body":"Follow-up to the clients upload migration shipped at `a21cebce`\n(Phase 6.1.1). New uploads write a `storage_objects` row that\nshares the document's `storage_key`; the download handler now\ndual-reads by probing `storage_objects` by `storage_key` +\nprefers the action-layer path on hit, falls back to the legacy\ndirect-driver presign on miss.\n\nSame shape as the admin /raw dual-read in Phase 4 commit 1\n(`19b80961`) — `client_documents` doesn't have a\n`storage_object_id` FK column yet, so the probe is by key\nrather than by id (like chat/support do post-migration).\n\n## What changed\n\n`modules/clients/src/actions/portal-documents.ts`\n(`getPortalDocumentDownloadUrl`):\n\n- After the existing client-scope + org-prefix checks, probes\n  `storage_objects` for a row matching\n  `(storage_key = doc.storageKey, org_id = ctx.orgId, deleted_at IS NULL)`.\n- On hit: invokes `getAction('storage.object.get_url')` with\n  `ttlSeconds: DOWNLOAD_TTL_MS / 1000` + `downloadAs: doc.name`.\n  The TTL matches the legacy 60-minute contract; the action\n  gets the refcount + AV + audit + access-registry treatment\n  uniformly.\n- On miss / action unavailable / non-ok: falls through to the\n  legacy direct-driver `presignDownload({ key, filename })`.\n  Persistent fallbacks logged at error level during cutover.\n\n## Why dual-read\n\nPre-migration documents have a populated `storage_key` but no\nmatching `storage_objects` row. Dropping the legacy presign\nwould 404 those forever. Both paths stay alive until the\nproducer migration backfills the gap (future commit) and the\n`clientDocuments` schema adds a `storage_object_id` FK column\nso the probe can become an exact lookup.\n\n## Soft-delete safety\n\nThe probe excludes `storage_objects.deleted_at IS NOT NULL`.\nA soft-deleted row falls through to the legacy presign — which\nalso serves the bytes from the bucket if they haven't been\nhard-deleted by the retention reaper yet. After hard-delete the\nlegacy path produces a presigned URL pointing at nothing, but\nthis aligns with the legacy behaviour pre-cutover.\n\n## Tests\n\n`modules/storage/src/lib/clients-portal-document-download-dual-read.integration.test.ts`\n(4 PGlite cases through the real `storage.object.get_url`):\n\n- post-migration `storage_key` matches a `storage_objects` row → FK\n- pre-migration `storage_key` with no matching row → legacy\n- soft-deleted row → falls through to legacy (probe excludes)\n- cross-org row → invisible to probe, falls through to legacy\n\n202 clients tests + 153 storage integration tests pass.\n\n## Producer-migration progress\n\nCounter at `createStorageClient` direct callers down by another\ntoward the eventual Phase 4 commit 4 Biome lint rule.\n\n## Reference\n\n- Handler: [modules/clients/src/actions/portal-documents.ts](../../modules/clients/src/actions/portal-documents.ts) (`getPortalDocumentDownloadUrl`)\n- Upload migration: `a21cebce` Phase 6.1.1\n- Pattern precedent: Phase 4 commit 1 (`19b80961`); chat `8f962923`; support `edc43331`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:38.136Z","updatedAt":"2026-06-22T20:54:38.136Z"},{"id":"4aa31310-3d37-4e3d-99c1-65be64b95658","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"drive-folder-actions","type":"added","scope":"drive","summary":"Drive folder actions (create / list / get / rename) + the full drive:* permission catalog.","body":"Phase 9.1a of the Drive comprehensive spec. First action surface ships\nbehind the schema landed in Phase 9.0.\n\nAdds:\n\n- `drive.folder.{create, list, get, rename}` actions — each with Zod\n  input/output schemas, scope-aware policies, the personal-scope Q15\n  fence enforced inline, slug auto-derivation from name, and the\n  cascade behaviors (depth cap, NULLS-NOT-DISTINCT slug uniqueness,\n  soft-delete exclusion).\n- 7 Drive events shipped (folder.created/renamed/moved/trashed/\n  untrashed + item.created/renamed) — subscribers land in Phase 9.6.\n- Drive policy module — `requires()` / `requiresAny()` helpers; one\n  policy per noun verb.\n- Full `drive:*` + `platform:drive:*` permission catalog: item\n  read/write/delete scoped `:own|:team|:any`; folder create/update/\n  delete; share + link + permission management; comment + revision +\n  legal_hold + WebDAV + editing gates. Catalog descriptions for each.\n- Standard role blueprint updates: manager gets team-scope drive perms;\n  employee gets own-scope; client gets read-on-shared + comment-write\n  only. Owner/admin auto-pick all non-platform perms via the existing\n  ALL_NON_PLATFORM_PERMS set; root cross-tenant via the all-perms set.\n- 21 integration tests proving happy-path + policy-denial + validation-\n  failure for each action, plus targeted coverage on NULLS NOT DISTINCT\n  collisions, depth cap, Q15 impersonation block, slug derivation, and\n  cross-org leak prevention.\n\nTest-development caught a real Drizzle behavior: PG error messages get\nwrapped in `Failed query: ...` and the underlying constraint name only\nappears via `error.cause`. The handler now walks the Error cause chain\n(+ aggregate `.errors[]`) to classify violations as `validation_failed`\ninstead of leaking them as `dependency_failed`.\n\nNext: Phase 9.1b — folder.{move, trash, untrash, create_shared_drive}.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:38.851Z","updatedAt":"2026-06-22T20:54:38.851Z"},{"id":"8c6187fe-c885-4002-aa33-570bed15e6a0","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"command-palette-module-gate","type":"fixed","scope":"web","summary":"The command palette (⌘K) no longer surfaces modules the current session can't access.","body":"The command palette's navigation results now respect the dynamic module\nregistry, matching the rail: a module hidden for the current session (kill-\nswitched, plan-excluded, an ungranted special module, or staff-only testing)\nno longer appears in ⌘K, so it can't be reached that way. Root operators still\nsee everything. Closes the gap where the rail hid a module but the palette\nstill navigated to it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:38.850Z","updatedAt":"2026-06-22T20:54:38.850Z"},{"id":"8f6dd133-3166-41fa-a607-af70d75dfca5","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"drive-module-schema","type":"added","scope":"drive","summary":"Helios Drive module — foundational schema for the Google-Drive-grade file management surface.","body":"Phase 9.0 of the Drive comprehensive spec\n([docs/plans/STORAGE_DRIVE_COMPREHENSIVE_SPEC.md](docs/plans/STORAGE_DRIVE_COMPREHENSIVE_SPEC.md)).\nLocked shape per\n[docs/plans/STORAGE_DRIVE_LIBRARY_PICKER_SHAPE.md](docs/plans/STORAGE_DRIVE_LIBRARY_PICKER_SHAPE.md) — Shape B\n(Drive scaffold pulled forward; library = picker context; full\ncollaboration from day one).\n\nShips:\n\n- New `modules/drive/` skeleton (CLAUDE.md, package.json,\n  tsconfig.json, src/{index,actions,events,jobs,schemas}/index.ts\n  placeholders). Action/event/job surface lands in Phase 9.1+.\n- 14 Drive schema tables: `drive_folders`,\n  `drive_shared_drive_members`, `drive_items`, `drive_permissions`,\n  `drive_share_links`, `drive_share_link_events`, `drive_comments`,\n  `drive_activity`, `drive_revisions`, `drive_co_editing_sessions`,\n  `drive_starred`, `drive_recent`, `drive_suggestions_cache`,\n  `drive_webdav_tokens`.\n- 1 new Storage-companion table: `storage_object_revisions` for the\n  version-history backing store.\n- Migration `0304_0305_drive_module_schema.sql` — all 15 tables +\n  indexes (partial unique on parent/slug uses `NULLS NOT DISTINCT`\n  so root-folder slugs collide) + CHECK constraints (depth ≤ 50,\n  kind / target_kind / principal_kind / permission_level / actor_type\n  enumerations, shortcut_target xor file kind).\n- 37-test schema-lock suite (`packages/db/src/schema/drive.test.ts`)\n  proving every CHECK fires, every unique index rejects dupes, FK\n  RESTRICT prevents byte-deletion while Drive holds a ref.\n\nDrive is a CONSUMER of the unified Storage module — every byte\nread/write delegates through `storage.object.*` actions. Never\nimports a storage SDK (enforced by\n[.claude/rules/storage-uploads.md](.claude/rules/storage-uploads.md)).\n\nNext: Phase 9.1 ships the `drive.item.*` + `drive.folder.*` action\nsurface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:39.165Z","updatedAt":"2026-06-22T20:54:39.165Z"},{"id":"e2b2e9a1-fd52-4f20-989a-2a42d5a3e957","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"drive-folder-lifecycle","type":"added","scope":"drive","summary":"Drive folder lifecycle actions — move (with descendant depth/path shift), trash (cascade), untrash (cascade + parent gate), create_shared_drive.","body":"Phase 9.1b of the Drive comprehensive spec. Completes the\n`drive.folder.*` action surface; folder creation/listing/get/rename\nshipped in Phase 9.1a (commit `b873006e`).\n\nAdds:\n\n- `drive.folder.move` — validates the new parent (same org, not\n  trashed, not the folder itself, not a descendant), recomputes\n  depth + the materialized path for the folder AND every descendant\n  in a single subtree update, refuses if the new subtree max-depth\n  would exceed 50, refuses on slug collision under the new parent,\n  refuses moves of shared-drive roots. Emits `drive.folder.moved`.\n\n- `drive.folder.trash` — soft-deletes the folder + every descendant\n  folder + every item in the subtree. Refused if any descendant item\n  is on legal hold (must be released first). Idempotent on\n  already-trashed folders. Emits `drive.folder.trashed` with cascade\n  counts. Marked `dangerous: true`.\n\n- `drive.folder.untrash` — restores the folder + cascade-trashed\n  descendants. Refuses if parent is still trashed (would orphan).\n  Refuses if a sibling now holds the same slug under the parent\n  (collision during the trash window). Preserves items trashed\n  independently (deleted_at timestamp differs from the cascade\n  timestamp). Emits `drive.folder.untrashed`.\n\n- `drive.folder.create_shared_drive` — Google-style \"Shared Drive\":\n  root folder with `is_shared_drive=true` + a member roster + the\n  actor auto-added as the first manager. Extra members from the\n  input add on top with their requested roles; dedups the actor\n  (their explicit role wins). Permissions inheritance OFF by default\n  — the roster is the only access source. Emits both\n  `drive.folder.created` AND `drive.shared_drive.created`.\n\n20 integration tests on top of Phase 9.1a's 21 (41 total drive\ntests, all green). Coverage includes happy-path + policy-denial +\nvalidation-failure for each action, plus:\n\n- move: cycle detection, depth-cap math on subtree, slug collision,\n  trashed-folder refusal, cross-org leak\n- trash: descendant + item cascade, legal-hold block, idempotent\n  re-trash\n- untrash: cascade restore, parent-still-trashed block, item\n  preservation when explicitly trashed pre-cascade\n- create_shared_drive: actor-as-first-manager, extra-member roles\n  with actor dedup, slug collision, personal-scope block, perm denial\n\nNext: Phase 9.1c — `drive.item.*` (12 actions).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":[],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:39.169Z","updatedAt":"2026-06-22T20:54:39.169Z"},{"id":"b4220ab8-11df-4ff8-b641-f177dc81ce59","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-inapp-raw-cents-and-empty-recipient","type":"fixed","scope":"email","summary":"Expense notifications format money, and credential emails greet the recipient by name instead of an empty string.","body":"Two unrelated rendering bugs surfaced by the second-pass email audit:\n\n**Expenses — raw cents shown to humans.** The in-app notification body\nfor `expenses.report.submitted` and `expenses.report.paid` rendered\n`${row.totalCents} ${currency}` verbatim, so a $123.45 reimbursement\nshowed up in the bell as \"12345 USD\" / \"12345 paid out\". Email\ntemplates were already correct (they use the `{{ x | money }}`\nfilter), but the in-app body is rendered as-is. Added a small\n`formatMoneyCents` helper that wraps Intl.NumberFormat with a graceful\n\"decimal + ISO code\" fallback for unknown currencies.\n\n**Expenses — `recipientName: 'Approver'` literal.** The submitted-\nreport email greeted the approver as \"Hi Approver\". `resolveApprover`\nnow returns the manager user's display name; the dispatch wires it\nthrough with the standard `name?.trim() || email.split('@')[0] ||\n'there'` cascade.\n\n**Projects credential reveal / rotation — `recipientName: ''`.** Both\nsubscribers were passing an empty string as the email variable,\noverriding the resolver cascade and yielding \"Hi \" in the rendered\ntemplate. Each subscriber now joins / batch-loads the recipient user\nrow and threads the resolved display name into the variables.\n`notify-on-credential-rotation-due.ts` fans out across project\nmembers, so its name lookup is a single `inArray()` batch — no N+1.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:39.451Z","updatedAt":"2026-06-22T20:54:39.451Z"},{"id":"6e1256e5-3f20-4839-a4c0-53c9b3480970","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-subscriber-dead-url-fix","type":"fixed","scope":"email","summary":"Email notifications now link to real routes (HRM employees, support tickets, project tasks, cycles).","body":"Same bug class as commit 68fe1a60 (mentions-inbox `/chat?focus=mentions`\n→ `/chat/mentions?kind=all`): subscribers were building absolute URLs\nagainst route paths that don't exist, so emails sent users to a 404.\n\nCaught by cross-checking every `${base}/<path>` string in\n`modules/*/src/jobs/email-*.ts` against the actual file routes under\n`apps/web/src/routes/`.\n\nFixed:\n\n- **HRM employee URL** — `/hrm/employees/<id>` → `/hrm/directory/<id>`\n  (actual route is `apps/web/src/routes/hrm/directory.$employeeId.tsx`).\n  Two sites: `email-on-hrm-events.ts` termination notice + signed-doc\n  notice (signed-doc collapses to the employee page since there's no\n  per-document route).\n- **Support ticket URL** — `/support/tickets/<id>` → `/support/<id>`\n  (actual route is `apps/web/src/routes/support.$ticketId.tsx`).\n  12+ sites across `email-on-extended-events.ts` (assigned, status\n  change, priority change, internal note, watcher added, follow-up\n  reminder) and `email-on-ticket-events.ts` (created, replied, CSAT\n  request).\n- **Support CSAT URL** — `/support/tickets/<id>/csat` → `/support/<id>`.\n  The CSAT survey is rendered inline on the ticket detail page when\n  the ticket is resolved/closed and has no rating yet (the page reads\n  `csatScore` from the ticket row). No dedicated route exists.\n- **Projects task URL** — `/projects/tasks/<task_id>` and\n  `/projects/<project_id>/<task_number>` → the deep-link search-param\n  path supported by `$projectId.index.tsx` since R25.2:\n  `/projects/<project_id>?task=<task_id>` (auto-opens the\n  `TaskDetailSheet`). Falls back to `/projects/tasks` (the task list)\n  when `project_id` is unknown. Five sites: `email-on-task-assigned`\n  + `email-on-task-events` (updated / due_soon / overdue + state\n  change) + `notify-on-task-completed`.\n- **Projects cycle URL** — `/projects/teams/<team_slug>/cycles/<id>`\n  → `/projects/cycles/<id>` (actual route is `cycles.$cycleId.tsx`;\n  there's no `/projects/teams/...` namespace).\n\nOut of scope (deliberate, will surface separately): the same\n`/support/tickets/<id>` and `/projects/<project_id>/<task_number>`\npatterns are also used by chat entity-preview / entity-references /\nentity-search actions and by a comment in `entity-link-chip.tsx`.\nThose are chat-owned and not part of the email polish pass, but\nthey're the same dead-link bug.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T21:54:36.200Z","updatedAt":"2026-06-22T21:54:36.200Z"},{"id":"4a378a4a-d248-477c-9498-0d2e52d76e53","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-leads-api-key","type":"added","scope":"forms","summary":"New API-key endpoint POST /api/forms/leads lets developers post leads into any inbound-enabled form with one org key.","body":"Developers and partners can now post leads into Helios with a single org-scoped\nAPI key (from Settings → Security) instead of wiring a per-form webhook token.\n`POST /api/forms/leads` takes an `Authorization: Bearer hak_…` (or `X-API-Key`)\nheader and a body carrying `formKey` plus the field values; the key binds the\norg, the form is resolved by its key, and the submission funnels into the same\n`forms.public.submit` pipeline as every other inbound path (validation,\nhoneypot, rate-limit, lead creation). It's the developer-facing companion to the\nexisting per-form `/api/forms/inbound/<id>` webhook; the form must be\ninbound-enabled.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T21:54:36.226Z","updatedAt":"2026-06-22T21:54:36.226Z"},{"id":"45bb9d67-c312-4430-9bc8-be90976fc84a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"email-platform-tier-hard-rule","type":"changed","scope":"email","summary":"Platform-tier emails (password reset, suspension notices, etc.) are now hard-pinned to platform branding regardless of any tenant's whitelabel feature.","body":"Closes a quiet correctness gap in the L.4 three-tier branding model:\nwhen a whitelabeled tenant org received a Helios-to-tenant\ncommunication (password reset, suspension notice, domain alert), the\nresolver flipped the chrome to that tenant's logo / colour — so the\nrecipient saw \"Acme Co\" branding on an email that was actually FROM\nHelios. That's the wrong signal — the recipient needs to recognize\nthe platform as the sender, not their workspace.\n\n**Hard rule.** `resolveBrandingVars` now forces\n`brandingMode='platform'` whenever:\n- The event class matches a platform-routed prefix (`auth.*`,\n  `saas.*`, `billing.*`, `email.outbound.failed.*` — same list the\n  routing engine uses for provider selection).\n- OR the dispatch `orgId === PLATFORM_ORG_ID` (changelog, status,\n  roadmap-followers, and any other sentinel-tenant send).\n\nUnder the hard rule both `whitelabelCap` and `brandingCap` pin to\nfalse (chrome AND \"Powered by\" credit follow platform settings).\nThe plan-feature lookup is skipped entirely — the override is\nunconditional, so the three `hasFeature` reads are pure overhead.\nTenant identity (`{{ orgName }}` etc.) still surfaces in body text\nwhere subscribers reference it (\"your Acme workspace has been\nsuspended\") — only chrome flips.\n\n**Org identity always surfaces (rule 2).** `orgTagline` now stays\norg-specific on every tenant-tier send regardless of plan — it's\nidentity data alongside `orgName` / `orgWebsiteUrl` / `supportEmail`\n/ `address` / `country` / `taxId` / `socialLinks` / `supportPhone`,\nnot chrome. A free-tier org's email footer shows their own tagline\neven though the logo + brand colours fall back to platform. This\nmatches the locked contract: chrome is paid for (whitelabel tier),\ncontact info is operator-supplied and always-visible.\n\nThreading: `email.outbound.send` passes `input.eventClass` into\n`resolveBrandingVars` so the resolver can apply the rule without\neach subscriber knowing about it.\n\nTest coverage: 9 new cases in `branding-context.test.ts` covering\n`saas.*` / `auth.*` / sentinel-orgId / Powered-by-still-visible /\nplan-lookup-skipped / non-platform-routed unchanged / omitted-\neventClass back-compat / org-identity-on-free-tier.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T21:54:36.227Z","updatedAt":"2026-06-22T21:54:36.227Z"},{"id":"f13170fe-7df7-4c53-a6fc-d7117346ddc4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"forms-leads-scope-gate","type":"security","scope":"forms","summary":"The /api/forms/leads endpoint now honors the API key's scope, so a narrowed key can't post leads beyond it.","body":"`POST /api/forms/leads` previously authorized lead ingestion on key validity +\norg binding alone, ignoring the key's `scopePermissions`. A key an operator\nscoped down (or one that leaked) could still create leads. The endpoint now\nrequires a scoped key to carry a forms/lead-write permission\n(`crm:lead:create` or `forms:definition:create|update`); unscoped keys (which\ncarry the user's full permissions) are unaffected. Returns `403\ninsufficient_scope` otherwise.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T21:54:36.441Z","updatedAt":"2026-06-22T21:54:36.441Z"},{"id":"d3711cac-55b5-4849-a015-8e3a425ba681","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-clients-email-subjects-empty-orgname-guards","type":"fixed","scope":"email","summary":"HRM + clients email subjects + bodies section-guard `{{ orgName }}` so empty-org degenerate cases don't render with awkward whitespace.","body":"Final batch of the empty-orgName subject-guard sweep started by\n855da0a1 (auth) and 9b48039d (iam). Same pattern, same fix.\n\nTemplates touched:\n- `hrm.employee.welcome` — \"Welcome to {{ orgName }}, {{ name }}!\" →\n  guarded so an unbranded org renders \"Welcome, {{ name }}!\"\n- `hrm.contract.revised`, `hrm.nda.revised`,\n  `hrm.joining_letter.revised` — \"Updated X from {{ orgName }}\" →\n  collapses cleanly to \"Updated X\" when empty.\n- `hrm.joining_pack.sent` — \"Your {{ orgName }} joining pack\" → \"Your\n  joining pack\". Body fallback \"Welcome to the team\" via inverted\n  section when no org name.\n- `clients.client.welcome` — same as the HRM welcome.\n\nEvery `{{ orgName }}` in subjects, preheaders, and body sentences now\nwraps in `{{#orgName}}…{{/orgName}}` section guards. The earlier auth\n+ iam sweeps closed the same gap for those modules.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:06:26.390Z","updatedAt":"2026-06-23T00:06:26.390Z"},{"id":"5df73683-8a8c-45e9-a199-e0921d81edf7","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"hrm-document-pdf-lazy-backfill","type":"changed","scope":"hrm","summary":"HRM document download read paths lazily backfill the storageObjectId FK on pre-bridge rows so subsequent reads serve via storage.object.get_url instead of the legacy direct presign.","body":"Companion to `cbe5ad57` (recruitment offer lazy backfill) — same\nshape applied to the two HRM document read paths so pre-bridge\nemployee documents (contracts / NDAs / joining letters) populate\ntheir `storageObjectId` FK on first read.\n\n## What changed\n\n`modules/hrm/src/actions/my-document-download.ts` (employee\nself-service) and `modules/hrm/src/actions/document-public.ts`\n(public-token signer) now trigger a render + Phase 6 bridge call\nwhen:\n\n- `doc.storageObjectId IS NULL` (pre-bridge row)\n- AND `doc.storageUrl` is set to a real bucket key\n- AND `doc.kind` is one of the renderable kinds\n  (`contract` / `nda` / `joining_letter`)\n\nOn success: the bridge returns a new `storageObjectId` which gets\nstamped on `employee_documents.storage_object_id`, and the action\nretries `storage.object.get_url` with the fresh FK so this very\ndownload benefits from the action-layer path immediately.\n\nOn failure: the code falls through to the existing legacy presign\nblock exactly as before. Pre-bridge bytes are still at the legacy\nkey from the original render, so the download never regresses.\n\n## Why a separate inline bridge call\n\nUnlike `renderOfferPdf` (recruitment) which calls its bridge\n(`cacheOfferPdf`) internally and returns `storageObjectId` in the\nresult, `renderHrmDocumentPdf` does NOT call `cacheHrmDocumentPdf`\n— that's a separate caller-side concern in the existing write\npaths (`document-revise`, `draft-joining-pack`, `send-joining-pack`).\n\nTo keep the refactor surface minimal, the read paths now mirror\nthe write-path pattern: render + bridge inline, without touching\nthe renderer or the four existing callers.\n\n## Tests\n\n`modules/hrm/src/actions/document-pdf-lazy-backfill.test.ts`\n— 8 schema-lock cases asserting:\n- Both files import `cacheHrmDocumentPdf`.\n- Both files gate lazy backfill on pre-bridge row + renderable kind.\n- Both files render with `upload: true` + call the bridge on the\n  result buffer.\n- Both files persist `storageObjectId` on a successful bridge call.\n- Both files fall through to the legacy presign on backfill failure.\n- Both files emit a structured log when the backfill runs.\n\n401 HRM tests pass (393 prior + 8 new).\n\n## Observability\n\nEach lazy-backfill firing logs:\n\n```\nhrm.employee.my_document_download: lazy backfill ran on pre-bridge row\nhrm.document.public.lookup: lazy backfill ran on pre-bridge row\n```\n\nFilter by these markers to track the backfill drain.\n\n## Producer-migration progress\n\nStep 1 of the HRM document-pdf renderer cleanup sequence\n(companion to `cbe5ad57` for recruitment). Once pre-bridge rows\nhave drained via this backfill, the Phase 4 step can drop the\nlegacy direct `putObject` in `document-pdf-render.tsx` — same\nshape as the receipt-PDF cleanup at `1151401b`.\n\n## Reference\n\n- Handlers:\n  - [modules/hrm/src/actions/my-document-download.ts](../../modules/hrm/src/actions/my-document-download.ts)\n  - [modules/hrm/src/actions/document-public.ts](../../modules/hrm/src/actions/document-public.ts)\n- Bridge: [modules/hrm/src/lib/document-storage-cache.ts](../../modules/hrm/src/lib/document-storage-cache.ts) (`cacheHrmDocumentPdf`)\n- Renderer (legacy PUT still in place): [modules/hrm/src/lib/document-pdf-render.tsx](../../modules/hrm/src/lib/document-pdf-render.tsx)\n- Companion: recruitment lazy backfill `cbe5ad57`; receipt-PDF cleanup `1151401b`","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:06:26.790Z","updatedAt":"2026-06-23T00:06:26.790Z"},{"id":"daf61e3a-c975-4caa-8d8e-228f80eacaf8","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"iam-email-subjects-empty-orgname-guards","type":"fixed","scope":"email","summary":"IAM email subjects + bodies section-guard `{{ orgName }}` so degenerate empty-org cases don't render with awkward whitespace.","body":"Follow-up to the auth-email polish in commit 855da0a1. Same pattern,\nsame fix, applied to every `iam.*` template:\n\n- `iam.invitation.invite`, `iam.invitation.accepted`,\n  `iam.invitation.cancelled`\n- `iam.membership.role_updated`, `iam.membership.removed`\n- `iam.ownership.transferred`\n- `iam.permission.custom_granted`, `iam.permission.custom_revoked`\n- `iam.role.granted`, `iam.role.revoked`\n- `iam.user.suspended`, `iam.user.reactivated`,\n  `iam.user.sessions_revoked`\n\nEvery `{{ orgName }}` reference now wraps in `{{#orgName}}…{{/orgName}}`\nsection guards so empty values (rare in IAM since these emails are\norg-bound, but possible when `organizations.name` is degenerate)\ncollapse cleanly instead of leaving double-spaces or trailing\npunctuation in the subject preview.\n\nThe inviter / invitee invitation templates also added\n`{{^orgName}} the workspace{{/orgName}}` inverted-section fallbacks\nso sentences like \"invited you to join …\" still read naturally\nwhen the org row has no name.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:22:47.535Z","updatedAt":"2026-06-23T00:22:47.535Z"},{"id":"d431c4c8-9aad-4174-b916-d24f95eebfda","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-compose-schedule-send","type":"added","scope":"mailbox","summary":"Compose dock — \"Schedule send for…\" button next to Send.","body":"Adds the M9.A UI entry point that was missing: a Clock-icon button\nnext to Send in the `/mail` compose dock. Clicking it opens a pop-out\nwith snooze-style presets (\"In 1 hour\", \"Tomorrow morning\", \"Next\nweek\", etc.). Picking a preset:\n\n- Calls `mailbox.draft.send_later` with `isUndoBuffer=false` so the\n  row surfaces in the Scheduled folder (separate from the 30s\n  undo-send buffer rows that the follow-up commit hides by default).\n- Toasts `Scheduled for <day, time>` on success.\n- Closes the dock + clears the localStorage draft.\n- Reuses the existing `snoozePresets()` helper so the time-of-day\n  labels match what the Snooze pop-out shows.\n\nPure addition — the regular Send path is unchanged. Shared-scope\naccounts get a `policy_denied` toast from the backend action since\nshared send-later ships with M13.\n\nThe `SchedulePopover` reuses the proven outside-click dismiss\npattern from `SnoozeButton`. Disabled (with `disabled:opacity-50`)\nwhen the form isn't `canSend`-ready.\n\nPending follow-ups: Scheduled folder in the sidebar (lists rows\nvia `mailbox.send_later.list_own`); 30s undo-send buffer routing\nfor the regular Send button (changes default behavior).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["closed-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:22:47.749Z","updatedAt":"2026-06-23T00:22:47.749Z"},{"id":"6a3421f5-4d37-496e-8d38-e7c5dfe287b4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-imap-tls-mode-autoconfig","type":"changed","scope":"mailbox","summary":"IMAP/SMTP connect — autoconfig discovery + 3-way TLS mode selector with better error hints.","body":"Polished the **Connect IMAP / SMTP** flow on `/settings/mailbox` so most operators\nonly have to type their email address:\n\n- **Autoconfig discovery** — typing your email and tabbing off the field now\n  looks up your provider's mail-server settings via the **Mozilla autoconfig**\n  XML spec (`https://autoconfig.<domain>/mail/config-v1.1.xml`), with fallback\n  to the **Thunderbird ISPDB catalog** and a DNS-verified common-pattern guess\n  (`mail.<domain>` / `imap.<domain>`). When a match is found the form\n  pre-fills host, port, TLS mode, and username for both IMAP + SMTP.\n\n- **3-way TLS mode selector** — replaced the binary \"Use TLS\" toggle with an\n  **Implicit TLS / STARTTLS / Plain** segmented control. The old toggle\n  couldn't distinguish \"TLS on connect (port 993)\" from \"STARTTLS upgrade\n  (port 143)\" and silently sent credentials over plaintext on misconfigured\n  STARTTLS hosts. Changing the port snaps the mode to the canonical pick\n  (993→Implicit TLS, 143/587→STARTTLS, 465→Implicit TLS) when you were on the\n  previous port's canonical mode; manual overrides are preserved.\n\n- **Better error messages** for the connection probe:\n  - Certificate hostname mismatch now extracts the cert's actual alt-name\n    and tells you which host to type instead (e.g. \"the cert covers\n    `*.saivra.co` — try `saivra.co`\").\n  - \"Wrong SSL version number\" is recognised as \"you picked Implicit TLS on\n    a STARTTLS-only port\" and suggests switching the mode.\n  - Greeting timeout / connection refused get distinct, action-oriented copy.\n\nThe legacy `secure: boolean` field on the connect-IMAP input remains accepted\nfor back-compat; when both are present `tlsMode` wins.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:22:47.765Z","updatedAt":"2026-06-23T00:22:47.765Z"},{"id":"3760dff7-5266-468b-b789-2cbdc7813a5a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-label-crud","type":"added","scope":"mailbox","summary":"M9.B — label CRUD actions (create / update / delete) for local labels.","body":"Closes M9.B's label surface: orgs can now create / rename / recolor /\ndelete LOCAL labels alongside provider-synced ones.\n\n- `mailbox.label.create({accountId, name, color?})` — inserts a new\n  row with `source='local'` and `isSystem=false` stamped server-side.\n  Clients cannot create provider-synced or system labels. Rejects a\n  duplicate name on the same account with `validation_failed`.\n\n- `mailbox.label.update({id, name?, color?})` — renames or recolors a\n  LOCAL label. Provider-synced labels are READ-ONLY here (the\n  upstream Gmail / Outlook owns their name + color); calling `update`\n  on one returns `policy_denied` with a hint to edit upstream. System\n  labels are likewise immutable. At least one of `name`/`color` must\n  be supplied. Rename collision against a live row returns\n  `validation_failed`.\n\n- `mailbox.label.delete({id})` — soft-deletes via `deletedAt`.\n  Marked `dangerous: true` since unlabeling can affect many threads\n  at once. Refuses provider-synced + system rows the same way\n  `update` does. Idempotent on already-deleted rows.\n\nColor validation accepts `#RRGGBB` hex literals (case-insensitive)\nor `null` for \"no color\". Name is trimmed + capped at 64 chars.\nOwnership is the existing `ownsAccount` helper from M9.B's first\nslice — personal/business scope only, impersonation fenced on\npersonal, shared rejected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:22:48.302Z","updatedAt":"2026-06-23T00:22:48.302Z"},{"id":"d906b9a0-530b-40d8-920e-85fd7d389339","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-label-create-inline","type":"added","scope":"mailbox","summary":"Label popover in /mail — search + inline \"Create '<name>'\" affordance.","body":"Brings the M9.B `mailbox.label.create` action to the surface where\nusers actually want it: the per-message label popover in the\nreader pane.\n\n- Adds a search input at the top of the popover. As you type, the\n  list filters by case-insensitive substring match on label name.\n  Reset + auto-focus on every popover open.\n- When the filter has content **and** no label exactly matches,\n  a \"Create '<name>'\" footer appears with a `↵` (Enter) hint. Press\n  Enter or click the footer to:\n  - Call `mailbox.label.create({accountId, name})` with the\n    trimmed text.\n  - Optimistically extend the account-wide label list cache so the\n    new label is selectable immediately.\n  - Immediately apply the new label to the current message via\n    `set_labels` (the 80% case — you're creating a label because\n    you want to label this thing).\n  - Clear the filter and re-focus the input so the next label\n    can be created in the same flow.\n- Empty-state copy updates: \"No labels yet — type a name above\n  and press Enter to create one.\"\n\nValidation: name is capped at 64 chars (matches the backend\nschema); whitespace is trimmed; empty input doesn't activate\nthe Create footer. Mirrors Linear / Notion command-palette\npatterns.\n\nRename / delete UI (per-label \"...\" menu) lands in a follow-up;\noperators can still call the backend actions directly today.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:22:48.020Z","updatedAt":"2026-06-23T00:22:48.020Z"},{"id":"eb01705d-6b9a-4a9f-899d-a30202fee33d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-label-actions","type":"added","scope":"mailbox","summary":"M9.B actions — `mailbox.label.list`, `mailbox.message.list_labels`, `mailbox.message.set_labels`.","body":"Wires the M9.B `mailbox_message_labels` join table to the action\nsurface. Three actions ship together; CRUD on the labels themselves\n(create / update / delete) is deferred — labels normally arrive via\nprovider sync (Gmail / Graph) and the 80% UI need is \"apply existing\nlabels to messages.\"\n\n- **`mailbox.label.list({accountId})`** — lists labels owned by an\n  account (excluding soft-deleted). Includes both provider-synced\n  and locally-created labels. Caller must own the account; shared\n  inboxes route through `mailbox.shared.*` (not yet wired for labels\n  in this slice).\n\n- **`mailbox.message.list_labels({messageId})`** — lists labels\n  currently applied to a message via a JOIN through\n  `mailbox_message_labels`. Returns the full LabelSummary so the UI\n  can render name + color + isSystem.\n\n- **`mailbox.message.set_labels({messageId, labelIds})`** — atomic\n  replace. Caller passes the desired post-state; server computes\n  the diff (`added` + `removed`) and applies it. Empty array\n  unlabels the message. Validation rejects unknown / cross-account\n  label ids with `validation_failed`. ON CONFLICT DO NOTHING on\n  insert guards against concurrent-tab races. Response carries\n  the final label state so the client doesn't need a follow-up\n  list call.\n\nPermissions: read paths use `mailbox:thread:read:own`; mutation\npaths use `mailbox:label:manage:own`. Both already exist in the\ncatalog.\n\nOwnership: every action verifies the actor owns the underlying\n`mailbox_accounts` row. Personal scope is fenced against\nimpersonation (Q15); shared scope is deferred. The shared\n`loadOwnedMessage` helper joins to the account row so message-\nlevel actions don't reinvent the wheel.\n\n14 schema-lock tests pin the wiring (action names, permission\ngates, ownership checks, atomic-replace shape). PGlite behavioural\ncoverage lands in a follow-up integration test.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T00:22:48.043Z","updatedAt":"2026-06-23T00:22:48.043Z"},{"id":"ae4d93e6-0c9d-4cca-8f2f-5f7a9fa2192a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-label-rename-delete","type":"added","scope":"mailbox","summary":"Label popover — inline rename + delete affordances on hover.","body":"Surfaces `mailbox.label.update` and `mailbox.label.delete` (shipped\nin M9.B's CRUD commit) where operators actually use them: directly\non each label row in the `/mail` reader-pane label popover.\n\n- **Hover affordances** — `PencilSimple` (rename) and `Trash`\n  (delete) icons appear on the right end of each LOCAL label row\n  on hover or keyboard focus. Hidden by default so the popover\n  stays calm.\n- **Rename inline** — clicking the pencil swaps the row for an\n  editable input pre-filled with the current name. Enter saves;\n  Esc cancels; blur cancels too. On success the cached label list\n  AND the applied-labels cache are both patched so the rename\n  reflects everywhere immediately (chips in the strip, checkbox\n  row in the popover, sidebar filter tree on next render).\n- **Delete confirm-in-row** — clicking the trash swaps the row\n  for a tight \"Delete '<name>'? [Delete] [Cancel]\" prompt. No\n  modal. On confirm: the row drops from both caches; on cancel\n  the row returns to normal.\n\n**Provider-synced + system labels** remain read-only — the icons\ndon't render on them. Provider rows now also display a small\n`sync` badge (mirroring the existing `sys` badge) so users\nunderstand why those rows can't be edited; the badge tooltip\npoints them at Gmail/Outlook for the upstream change.\n\nMutations both inherit the popover's existing toast-on-error\npattern (validation_failed for rename collision; policy_denied\nfor the rare race against a provider re-sync).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T03:25:56.590Z","updatedAt":"2026-06-23T03:25:56.590Z"},{"id":"fc376f6f-57df-4bdd-8fc6-4ac1c5e22e4d","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-label-sidebar-filter","type":"added","scope":"mailbox","summary":"M9.B — labels list in the sidebar; clicking filters threads by label.","body":"Extends `mailbox.thread.list` with an optional `labelId` filter and\nadds a Labels section to the `/mail` sidebar so users can browse\ntheir account's labels and one-click filter the thread list.\n\n**Action change:**\n- `mailbox.thread.list` accepts `labelId?: string (uuid)`. When set,\n  the SQL adds an `EXISTS` subquery via `mailbox_message_labels` so\n  threads with at least one labeled message surface. The EXISTS\n  form (not an INNER JOIN) preserves 1-row-per-thread cardinality\n  even when a label is applied across multiple messages in the\n  same thread. Composes with `folder` (e.g. \"Inbox tagged with X\").\n\n**UI:**\n- New `LabelTree` component below `FolderTree` in the active\n  account's sidebar section. Lists every label via\n  `mailbox.label.list`; each row shows a small color dot + name.\n  Clicking a label sets the filter; clicking the active label\n  clears it. A \"Clear\" affordance in the section header is the\n  single-click escape hatch.\n- Hidden entirely while the account has no labels (the common case\n  for fresh accounts before provider sync).\n- Hidden for shared inboxes since `mailbox.label.*` rejects shared\n  scope.\n- The label filter resets on account / folder / search change so\n  navigating away never leaves a stale filter.\n\n**Tests:** 5 schema-lock tests pin the SQL shape (EXISTS not JOIN,\nsoft-deleted message exclusion, thread/label id binding) so a\nfuture refactor can't silently regress the cardinality invariant.\n\nPending: optional thread-row chips showing the union of labels\nacross messages; label CRUD (`create`/`update`/`delete`) actions\nfor local-only labels.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T03:25:56.641Z","updatedAt":"2026-06-23T03:25:56.641Z"},{"id":"333d4572-3487-41f1-9eb2-fcee2ec50d84","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-label-ui","type":"added","scope":"mailbox","summary":"M9.B UI — per-message label chips + add/remove popover in the thread reader.","body":"Wires the M9.B label actions into the `/mail` reader. Each expanded\nMessageBubble now shows a small label strip:\n\n- **Applied labels** render as colored chips (using the label's\n  `color` from the row) with an X icon. Click to remove —\n  `mailbox.message.set_labels` is called with the remaining set.\n- **+ Label** button opens a popover with every label on the\n  account (loaded via `mailbox.label.list`, cached across messages\n  for 60s so toggling between bubbles doesn't refetch). Each label\n  is a checkbox; clicking toggles via `set_labels` atomically.\n- Server returns the authoritative post-state; the UI writes it\n  into the applied-labels cache so chips update without a\n  follow-up `list_labels` round-trip.\n- System labels carry a small `sys` tag in the popover so users\n  understand which ones are Helios-defined vs Gmail-synced.\n- Outside-click dismisses the popover.\n\n**Hidden for shared inboxes and observer roles** since\n`mailbox.label.*` reject shared scope (deferred to M13).\n\nThreading the `accountId` through `ThreadReader` → `MessageBubble`\nwas the only API change required — the existing `messageGetAction`\nprop already discriminated personal vs shared.\n\nPending follow-ups: sidebar label list with thread-filter; label\nCRUD (`create`/`update`/`delete`) for orgs that want local-only\nlabels alongside provider-synced ones.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T03:25:58.239Z","updatedAt":"2026-06-23T03:25:58.239Z"},{"id":"d336fb68-4100-4c48-a995-1475da140962","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-send-later-actions","type":"added","scope":"mailbox","summary":"M9.A actions — `mailbox.draft.send_later`, `mailbox.send_later.cancel`, `mailbox.send_later.list_own`.","body":"Three new actions on top of the `mailbox_send_later` table shipped\nin `14e504e2`:\n\n- **`mailbox.draft.send_later({...SendInput, sendAt, isUndoBuffer})`**\n  — enqueues a future-scheduled send. Validates account ownership +\n  active status + 1s/6m horizon. Refuses shared-scope accounts (the\n  shared variant ships with M13). Personal-scope accounts refuse\n  while impersonating (Q15 fence). Strips `sendAt` + `isUndoBuffer`\n  from the inline jsonb payload so the drain action replays it\n  through `mailbox.message.send` verbatim.\n- **`mailbox.send_later.cancel({id})`** — flips a `pending` row to\n  `canceled` via a race-safe conditional UPDATE on\n  `status='pending'`. Returns the post-call status so the caller's\n  toast renders correctly (`canceled` / `sent` / `sending` /\n  `failed`). Ownership enforced inline. Idempotent on already-\n  terminal rows.\n- **`mailbox.send_later.list_own({accountId?, cursor?, limit?,\n  hideUndoBuffers?, statusIn?})`** — paginated list (cursor by\n  `createdAt DESC`). Default hides the 30-second undo-send buffer\n  rows so the Scheduled folder shows only operator-deliberate\n  scheduled sends. Default statuses are `pending + sending`;\n  history views pass `statusIn` to include sent/canceled/failed.\n  Uses `limit + 1` to detect next page without a COUNT.\n\n26 schema-lock tests pin every load-bearing decision: action names,\nownership guards, impersonation fence, shared-scope rejection,\nhorizon validation, payload-jsonb strip, race-safe cancel UPDATE,\ndefault-hide undo buffers, default statuses, cursor pagination.\n\nPending follow-ups (deliberately split for collision avoidance):\nthe drain sweep helper, the worker cron, the dock's \"Schedule\nsend for…\" pop-out, the dock's Undo toast routing every regular\nSend through the 30s buffer, the `/mail` Scheduled folder.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T03:25:58.329Z","updatedAt":"2026-06-23T03:25:58.329Z"},{"id":"c7614991-6659-43e3-8b88-f95cd061f0ca","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-scheduled-folder","type":"added","scope":"mailbox","summary":"`/mail` Scheduled folder — lists `mailbox.send_later.list_own` rows + per-row Cancel.","body":"Adds a \"Scheduled\" folder to the `/mail` sidebar between Snoozed\nand Archive. When active, the center pane replaces the regular\nthread list with a `ScheduledList` of `mailbox.send_later.list_own`\nrows (the action's default `hideUndoBuffers=true` is respected so\nthe 30s undo-send buffer rows don't surface — only operator-\ndeliberate schedules do).\n\nEach row shows:\n- Subject + recipient preview (first 2 emails + \"+N\" overflow).\n- Scheduled send time, formatted as the same day+time string the\n  Snooze pop-out uses.\n- A status pill: `pending` (slate), `sending` (amber), `sent`\n  (emerald), `failed` (rose), `canceled` (slate, struck through).\n- Inline error message when `status='failed'`.\n- A **Cancel** button (visible only when `status='pending'`) that\n  calls `mailbox.send_later.cancel`. The toast reports truth: if\n  the cron beat the cancel, the action's fresh-read fallback\n  returns the actual status (`sent` / `sending` / `failed`) and\n  the UI surfaces \"Already sent\" or \"Cancel may not take effect\".\n\nAfter a cancel, the list is invalidated so the row's new status\n(or its removal) is reflected immediately.\n\nThe Scheduled folder doesn't open a reader — selecting a row is a\nno-op for now (the right pane stays at the \"Select a thread\"\nplaceholder). A future commit may add a payload preview pane.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T03:25:58.337Z","updatedAt":"2026-06-23T03:25:58.337Z"},{"id":"0f3dd307-cec5-404d-a751-1eab956806c4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-send-later-drain","type":"added","scope":"mailbox","summary":"M9.A drain sweep helper — claims pending+due `mailbox_send_later` rows and dispatches via `mailbox.message.send`.","body":"Closes the worker-side half of M9.A: a sweep helper that periodically\nwalks `mailbox_send_later` for pending+due rows, claims them\natomically, and dispatches each through `mailbox.message.send`.\n\n- **Race-safe claim** — single UPDATE with an `IN (...FOR UPDATE\n  SKIP LOCKED)` subquery flips the oldest-due `pending` rows to\n  `sending`. Multi-replica drains split the batch without overlap;\n  a simultaneous user `mailbox.send_later.cancel` either lands\n  before claim (clean cancel) or loses cleanly and the toast\n  reports the truth via the action's fresh re-read.\n- **Per-row dispatch via getAction + invoke** — system context\n  quotes the originating user as `actorId` so the action's policy\n  gate sees the right ownership + impersonation context.\n- **Race-safe settle** — both the success path (`sent` + record\n  `providerMessageId`) and the failure path (`failed` + record\n  `errorMessage`) UPDATE with `WHERE status='sending'` so a late-\n  arriving cancel can't accidentally flip a settled row back.\n- **No automatic retry in v1** — operators see the failed row in\n  the Scheduled folder and re-schedule manually. Wraps the dispatch\n  in try/catch so a provider throw doesn't crash the cron tick.\n\nReturns `{claimed, sent, failed, errors}` counts so the cron\nheartbeat can render the drain rate; non-zero `failed + errors`\ntrips the warn logger so on-call sees provider regressions before\nusers do.\n\n12 schema-lock tests pin the FOR UPDATE SKIP LOCKED, oldest-due\nordering, batch cap, race-safe settle, getAction dispatch path,\nper-row try/catch, and the result shape.\n\nPending follow-up: the worker cron (`apps/worker/src/mailbox-send-\nlater-cron.ts`) + boot wiring — deliberately split to avoid\nsweeping foreign-session edits on `apps/worker/src/index.ts`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:06.872Z","updatedAt":"2026-06-23T06:01:06.872Z"},{"id":"8e938db6-bde2-49f7-aa7a-f0a18262253a","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-undo-send-buffer","type":"changed","scope":"mailbox","summary":"Compose dock — every regular Send (personal + business) now passes through a 30s undo buffer with an Undo toast.","body":"Completes the M9.A UI by routing every regular Send (personal +\nbusiness scope mailboxes) through the 30-second undo-send buffer\nthat the backend has been waiting on.\n\n**Personal/business mailboxes:**\n- Send button calls `mailbox.draft.send_later` with\n  `sendAt = now + 30s` and `isUndoBuffer = true`.\n- Dock closes + draft clears immediately (the buffer row is\n  durable in Postgres).\n- Persistent toast renders: \"Sending…\" + \"Will go out in ~30\n  seconds.\" + **Undo** action button.\n- Toast auto-dismisses after 30s; the drain cron claims the row\n  at the 10s tick following expiry and dispatches via\n  `mailbox.message.send`.\n- Clicking Undo calls `mailbox.send_later.cancel`:\n  - `canceled` → \"Send canceled.\"\n  - `sent` (cron beat us) → \"Already sent — cannot undo.\"\n  - `sending` / `failed` → \"Cancel may not take effect.\"\n\n**Shared inboxes** keep using the direct send path\n(`mailbox.shared.message.send`) because the shared send-later\nvariant ships with M13.\n\n**Fallback** — if the buffer enqueue itself errors (account auth\nexpired, backend down, etc.), the dock falls back to the direct\nsend path so the user isn't blocked. They lose the Undo window\nbut the message still ships.\n\nThe `UNDO_WINDOW_MS = 30_000` constant is the canonical Gmail/\nSuperhuman default. Combined with the cron's 10-second tick, the\neffective race is `[20s, 30s]` from Send click. Per-account\ncustomisation (some operators want 5s, some 60s) lands in a\nfollow-up.\n\nThe Scheduled folder's default `hideUndoBuffers=true` means these\n30s rows DON'T surface there — they're a transient bookkeeping\nconcern. Operator-deliberate \"Schedule send for…\" rows still\nappear normally.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:07.056Z","updatedAt":"2026-06-23T06:01:07.056Z"},{"id":"3c7b9572-286d-4075-a9ca-5fd511a9ee8f","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"marketing-changelog-version","type":"added","scope":"marketing","summary":"The public changelog page now shows each release's SemVer version next to its tag.","body":"The public `/changelog` page now renders each release's version (e.g.\n`v0.10.0`) beside the release tag, matching the in-app \"what's new\" view. The\nversion flows from the platform changelog read API; releases cut before\nversion-stamping simply omit the pill.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:06.991Z","updatedAt":"2026-06-23T06:01:06.991Z"},{"id":"7f371388-f5ae-40b8-8c3a-2c5ae8640a9e","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-send-later-cron-file","type":"infra","scope":"mailbox","summary":"Worker cron file for the M9.A send-later drain (boot wiring in a follow-up commit).","body":"Adds the worker cron file that calls `runSendLaterDrain` every 10\nseconds. Short interval is intentional: the 30-second undo-send\nbuffer expects rows to leave `pending` within ~10s of expiry so\nthe user's \"Send\" feels prompt while the Undo toast was still\nactionable.\n\nWraps the helper with the worker's standard cron lifecycle: AbortSignal cancellation, in-flight `busy` guard so a slow provider can't pile up overlapping ticks, info-level log when the tick claims rows, error-level log when the tick throws. The helper itself's `failed + errors` non-zero case trips its own `warn` logger so on-call sees provider regressions early.\n\n**Boot wiring deferred** to a follow-up commit. A parallel session\nis editing `apps/worker/src/index.ts` in this window (their own\nimport for `startEmailInboundLegacyImportCron` is uncommitted in\nthe working tree). Per the parallel-sessions rule I leave foreign\nWIP untouched; once their commit lands my surgical addition\nfollows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:05.807Z","updatedAt":"2026-06-23T06:01:05.807Z"},{"id":"6eec7d7f-2b7e-4f1b-9324-19956ca5b251","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-send-later-drain-integration-tests","type":"infra","scope":"mailbox","summary":"PGlite integration tests for the M9.A send-later drain — happy path + race-safe settle + provider error mapping verified.","body":"End-to-end PGlite tests for `runSendLaterDrain`. The earlier schema-\nlock tests pinned the structural shape; these exercise the actual\nruntime behaviour against real Postgres-shaped state via the stub\nprovider runtime that `message-send.test.ts` already proves out.\n\n8 cases:\n\n- **Happy path** — pending+due rows flip to `sent` and persist\n  `providerMessageId` from the provider stub.\n- **Non-due rows stay pending** — `sendAt` in the future means\n  `claimed=0` and the row's status is preserved.\n- **Canceled rows are not claimed** — `status='canceled'` is\n  invisible to the claim subquery.\n- **Already-sent rows are not re-claimed** — idempotent across\n  ticks; finished work stays finished.\n- **Provider error → failed** — when the stub throws a\n  `ProviderError`, the row flips to `status='failed'` with the\n  error message persisted and `sent_at` left null.\n- **maxClaim batch cap** — three due rows + `maxClaim=2` claims\n  exactly 2; the third stays pending.\n- **Oldest-due-first ordering** — with `maxClaim=1`, the older row\n  drains before the newer one (matches the `ORDER BY send_at ASC`\n  in the SKIP LOCKED subquery).\n- **Empty-tick shape** — no pending rows returns\n  `{claimed:0, sent:0, failed:0, errors:0}`.\n\nThe drain helper's `FOR UPDATE SKIP LOCKED` claim is exercised\nimplicitly: every test inserts then drains, so the conditional\nUPDATE's `RETURNING` walk is the only path that could fire. The\nrace-safe settle's `WHERE status='sending'` guard is exercised\nimplicitly by the success + failure paths — both must filter on\nthat status, and both pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:05.984Z","updatedAt":"2026-06-23T06:01:05.984Z"},{"id":"a07a73a1-72fd-4a5a-b725-53271f9381b4","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"mailbox-send-later-schema","type":"infra","scope":"mailbox","summary":"Schema for M9 send-later + 30s undo-send buffer (table + indexes; actions + cron in follow-up).","body":"Adds `mailbox_send_later` table — the durable representation for\nboth **explicit \"Schedule send for…\"** rows AND the **30-second\nundo-send buffer** that every regular send passes through.\n\nLifecycle:\n- `pending` → (cron `FOR UPDATE SKIP LOCKED` claim) → `sending` →\n  `sent` | `failed`.\n- `pending` → `canceled` via `mailbox.send_later.cancel` (the UI's\n  Undo toast for undo-buffer rows; the Scheduled folder's Cancel\n  button for explicit scheduled rows).\n\nThe `is_undo_buffer` boolean distinguishes the two kinds so the UI\ncan hide the 30-second buffer rows from the Scheduled folder while\nstill surfacing them in the Undo toast.\n\nThe full `SendRequest` payload lives inline as `jsonb` so the drain\naction can replay it through `mailbox.message.send` (or the shared\nvariant) at drain time without joining additional tables.\n\nThree indexes:\n- `mailbox_send_later_pending_due_idx` (partial on `status='pending'`)\n  — the cron's hot path.\n- `mailbox_send_later_user_idx (user_id, created_at)` — per-user\n  Scheduled folder query.\n- `mailbox_send_later_account_idx (account_id)` — for the disconnect\n  sweep.\n\nThe actions (`mailbox.draft.send_later`, `mailbox.send_later.cancel`,\n`mailbox.send_later.list_own`) + the drain sweep helper + the cron\nland in follow-up commits. The drizzle `_journal.json` entry that\nmakes the migration runnable also lands in a follow-up (a parallel\nsession is editing the journal file in the same window; per the\nparallel-sessions rule I leave their WIP untouched).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:06.207Z","updatedAt":"2026-06-23T06:01:06.207Z"},{"id":"c2c38cb5-1da5-4c00-8de5-72435ae3ad3c","releaseId":"430bf67f-d471-408b-bc52-75131e8f6c27","slug":"marketing-maturity-banner","type":"added","scope":"marketing","summary":"The public marketing site shows an alpha/beta maturity banner when the product isn't stable.","body":"When the product is in `alpha` or `beta` (`platform_settings.app_status`), the\npublic marketing site now shows a thin maturity banner at the top of every page\n— reassuring copy, a \"Learn more\" link, and a per-stage dismiss. It reads the\nstage from `platform.branding.public`, uses the operator's app name (never a\nhard-coded brand), and hides itself entirely once the product is stable. This\nmirrors the in-app maturity banner so the stage is honest on both surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-23T06:01:07.241Z","updatedAt":"2026-06-23T06:01:07.241Z"}]},{"id":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","tag":"W2026-24","slug":"w-w2026-24","version":null,"title":"W2026-24 — 1 change this week","summary":"Auto-published weekly digest. Covers 1 change from 2026-06-08 merged into main.","status":"published","publishedAt":"2026-06-08T16:37:57.681Z","periodStartsAt":null,"periodEndsAt":"2026-06-08T16:37:57.681Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["auto","weekly"],"createdAt":"2026-06-08T16:37:57.682Z","updatedAt":"2026-06-08T16:37:57.688Z","entries":[{"id":"2dc7bf8d-8cff-4ed8-8c8a-cfd4cdfe4eee","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"plans-rework-phase-e2-locked-feature-catalog","type":"added","scope":"web","summary":"`/settings/billing` now shows an \"Available on other plans\" section — every catalog feature the current plan lacks but other plans unlock, with a chip linking to the relevant plan in the picker.","body":"Phase L.5 Phase E2 of the plans rework. Builds on the Phase E1\nusage dashboard with the next tenant-facing surface from spec\n§5.2.\n\n**What changed.**\n\nA new `LockedFeatureCatalog` card surfaces below the usage\ndashboard on `/settings/billing`. For every public catalog entry\n(`publicVisible: true`, not `deprecated`) that the tenant's\ncurrent plan lacks but at least one other plan enables, the\nsection renders:\n\n- The feature's `marketingLabel` (operator-curated upsell copy\n  from L.5 Phase A1) — falls through to `label` when not set.\n- The `marketingDescription` — concise explanation of what the\n  feature unlocks.\n- One chip per plan that includes it — clicking jumps to the\n  plan picker via the `#plan-<slug>` deep-link (matching the\n  Phase E5 anchor pattern).\n\n**Logic.**\n\nPure-render. Reads `publicCatalogEntries()` once + iterates the\nalready-fetched `plans` array. No additional DB / action calls.\n\nPer-entry \"currently has\" / \"unlocks\" check:\n- Boolean → `value === true`.\n- Number → `typeof value === 'number' && value > 0`.\n- module_array entries are skipped — the marketing card above\n  the table already shows the module list.\n\nArchived plans are excluded from the \"unlocked by\" chip\ncandidates so the tenant never sees a plan they can't actually\nupgrade to.\n\nSection silently renders nothing when:\n- Tenant has no current plan (signup flow, pre-trial).\n- Only one plan exists in the catalog.\n- No locked features exist (Enterprise / unlimited plans).\n\n**Operator-edited upsell copy.** The chip labels + descriptions\nflow from the L.5 catalog v2 metadata an operator edits in\n`/saas/plans`. Editing `marketingLabel` immediately changes\nwhat tenants see on `/settings/billing` after the next page load\n— no code change.\n\nSpec: docs/plans/PLANS_REWORK_SPEC.md §5.2.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:24.492Z","updatedAt":"2026-06-13T19:04:24.492Z"},{"id":"fef892e7-51bc-442f-a113-1cf0c2e71548","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"deal-created-event-enrichment","type":"changed","scope":"crm","summary":"New-deal automations can now route on deal value, win probability, owner, and source.","body":"The `crm.deal.created` event now carries `amountCents`, `currency`,\n`probability`, `companyId`, `ownerId`, and `source` alongside its title and\nstage. That unlocks value- and probability-based automations: trigger filters\nlike `{{ probability }}` greater than 60, or a branch on `{{ amountCents }}` to\nescalate large deals. Two new templates ship with it — \"Large-deal escalation\"\n(branches on deal value) and \"High-probability fast-track\" (filters on win\nprobability) — bringing the template gallery to 19. `amountCents` is integer\nminor units (the canonical storage unit); fields are optional on the event so\nexisting subscribers are unaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:33:31.917Z","updatedAt":"2026-06-13T13:33:31.917Z"},{"id":"227ce357-7fee-46fd-93bc-413367f4a106","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-client-spaces-access-model","type":"changed","scope":"chat","summary":"Client Spaces access — members always see their own space; admins now hold the gate keys by default; spec updated.","body":"User clarified the intended access model for Client Spaces. Three\nshifts:\n\n- **Member-or-gate visibility on read paths.** `chat.space.list` and\n  `chat.space.get` previously hid client spaces from anyone lacking\n  `chat:client_space:read`, including the participating client user\n  themselves (a `users.type='client'` row added as a space member).\n  That broke the client's only chat-entry-point. Rule is now\n  **\"member OR has gate\"**: members always see their space; non-\n  members still need the permission key. Closes the orphaning bug.\n- **Default access for admins.** `chat:client_space:{read, create,\n  manage_members, delete}` now land in the admin role blueprint\n  alongside the existing `chat:admin` umbrella. Owners + root still\n  get them via the all-permissions root grant. Managers and\n  sales-team employees still get NO default access — must be added\n  per-user via Settings → Users → Permissions or via a custom role.\n- **Spec doc updated.** `docs/plans/CHAT_CLIENT_SPACES_SPEC.md`\n  Permissions section now carries a full access-model matrix\n  documenting which populations get default access and the\n  configuration path for everyone else.\n\n194 chat tests still pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:33:32.676Z","updatedAt":"2026-06-13T13:33:32.676Z"},{"id":"b2f1b5f1-357e-4e7b-bff9-530d08571726","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-hybrid-dm-scope","type":"added","scope":"chat","summary":"DMs can be pinned to a single space per user (org-wide default + hybrid pin).","body":"DMs and group DMs stay org-wide by default — they follow you across every\nspace, the way Slack handles them. New behaviour: a user can **pin** a\nspecific DM to a single space, so the DM only renders there for them. Useful\nfor \"the design DM belongs in my Design space; the chitchat DM belongs in my\nteam space; everything else is org-wide\".\n\n**Schema** — new `chat_dm_space_pins (user_id, channel_id, space_id,\npinned_at)` table, PK `(user_id, channel_id)`. Per-user — different users can\npin the same DM to different spaces. Cascade with users / channels / spaces\nso no dangling pins survive.\n\n**Actions** —\n\n- `chat.dm.pin_to_space(channelId, spaceId)` — sets the pin. Refuses non-DM\n  channels, DMs the actor isn't a member of, and target spaces the actor\n  isn't a member of. Re-pinning overwrites.\n- `chat.dm.unpin_from_space(channelId)` — clears the pin; idempotent\n  (`unpinned=false` when no pin existed). The DM returns to org-wide\n  visibility.\n\n**Read path** — `chat.channel.list` joins the pins table and returns a new\n`pinnedToSpaceId` field per DM row. The sidebar filter (`inActiveSpace`)\nalready honours it:\n\n- `dm` / `group_dm` with no pin → render in every space.\n- `dm` / `group_dm` with `pinnedToSpaceId === activeSpaceId` → render here.\n- `dm` / `group_dm` with a different pin → hidden in this space.\n\nChannel-style rooms (`public` / `private` / `announcement`) still filter by\ntheir own `space_id` exactly as before.\n\nA context-menu surface on the DM row to pick a space (or \"show across all\nspaces\") is the planned follow-up; the backend + filter are wired so the\nfeature works end-to-end via the action API today. 240 chat tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:10.931Z","updatedAt":"2026-06-15T15:59:10.931Z"},{"id":"fafa9825-ca97-4da7-b721-50c714fa9ce1","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-subdomain-routing","type":"added","scope":"support","summary":"A verified subdomain or custom domain can now serve an organization's help center.","body":"A host authorised for the `support` or `kb` surface (e.g.\n`support.acme.com` or a verified subdomain) now serves that organization's\nhelp center: the request rewriter maps the host root `/` to `/help` and\ninjects `?org=<slug>` into `/help/*`, so visitors see Acme's KB, docs,\nservices, and contact form instead of the platform default. Idempotent —\nan already-scoped help URL is left untouched. See\ndocs/plans/SUPPORT_PLATFORM_VS_ORG_SPEC.md §B3.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:33:33.018Z","updatedAt":"2026-06-13T13:33:33.018Z"},{"id":"b45df7c2-fea4-497e-a041-ff788e34f43f","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"deal-close-event-enrichment","type":"changed","scope":"crm","summary":"Deal won/lost/stage-changed automations can now route on deal value and owner.","body":"The `crm.deal.won`, `crm.deal.lost`, and `crm.deal.stage_changed` events now\ncarry `amountCents`, `currency`, and `ownerId` (read from the deal in the same\nquery that already loaded its title), matching the enrichment already on\n`crm.deal.created`. Automations triggered on a closed or moved deal can now\nfilter and personalize on its value and owner — e.g. only run the VIP win\nsequence when `{{ amountCents }}` is large. `amountCents` is integer minor units;\nfields are optional on the events so existing subscribers are unaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:41.543Z","updatedAt":"2026-06-13T14:09:41.543Z"},{"id":"482c6094-27a9-4215-98d6-0997ce0c812e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"platform-support-perm-and-seed","type":"added","scope":"support","summary":"Added a root-only platform-support permission and seed the platform's own helpdesk defaults.","body":"Introduced `platform:support:manage` — the root-only gate for managing\nOdexy's own helpdesk (the platform organization's support workspace), the\nsupport analogue of `platform:payment_gateway:manage`. It is never granted\nby a standard role blueprint.\n\nThe platform seed now provisions the platform org's support configuration\n(statuses, priorities, ticket types, default group, business hours) so\nOdexy's helpdesk is functional out of the box. Foundation for the\n`/saas/support` console. See docs/plans/SUPPORT_PLATFORM_VS_ORG_SPEC.md §B2.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.777Z","updatedAt":"2026-06-13T14:09:42.777Z"},{"id":"b99ffddc-f7cd-49ee-a956-44d2d5d041a8","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-2a-public-board","type":"added","scope":"roadmap","summary":"Added the public roadmap surface at /help/roadmap — browseable board, three-column kanban, and per-feature detail pages.","body":"Phase 2a of the platform Roadmap & Feature Request module — the\ncustomer-facing read surface.\n\n**Three new public actions** (visibility-tier filtered, no auth\nrequired):\n\n- `platform.roadmap.feature.list_public` — browseable board read.\n  Hides drafts (`open`, `under_review`) by default — those are admin-\n  triage signal, not customer-visible. Honors the four visibility\n  tiers (`public` always; `authed` for sessions; `customer_only` +\n  `private` for `platform:roadmap:manage`). Supports\n  category / status / search filters, four sort options\n  (trending default), pagination + total count.\n- `platform.roadmap.feature.get_public` — single-feature read by\n  slug. When the slug points at a feature that was merged away, walks\n  `meta.merged_into` once and returns the canonical row plus a\n  `redirectedToSlug` hint so the UI can render a \"redirected from\"\n  banner instead of a 404 (forward-compatible with Phase 6 merge).\n- `platform.roadmap.board.summary_public` — three-column kanban shape\n  (Planned / In Progress / Shipped) for the in-app `/help/roadmap`\n  kanban tab and the future marketing apex. Includes hero copy +\n  disclaimer + the `publicReadEnabled` kill-switch flag.\n\n**New UI:**\n\n- `/help/roadmap` — public-facing roadmap page. Two tabs: **Roadmap**\n  (kanban with hero copy from settings) and **Browse all** (filterable\n  list with search / category / status / sort chips). Cards link into\n  the detail page. When `publicReadEnabled` is off, renders a \"preview\"\n  placeholder instead.\n- `/help/roadmap/$slug` — single-feature detail page. Shows\n  title, status pill, category badge, target label, summary, body\n  (markdown plain-text rendering), tags, \"Why we declined this\" /\n  \"What we shipped\" callouts for terminal states, and a signal sidebar\n  with vote / follower / comment counts. Voting + commenting UI lands\n  in Phase 2b/3 — for now the sidebar carries a \"voting will be\n  enabled shortly\" hint.\n- New **Roadmap** tab in the public help layout\n  (`apps/web/src/routes/help.tsx`) between Changelog and Status.\n\n**Tests:** 8 new vitest cases over the public actions covering\nhappy paths, the validation-failure path (out-of-range limit, invalid\nslug format), and the unknown-slug → null path. All 19 module tests\ngreen.\n\nPhase 2b adds vote.toggle + follow.toggle + the interactive sidebar.\nPhase 2c adds the gated \"Request a feature\" submit flow + pgvector\ndedup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.833Z","updatedAt":"2026-06-13T14:09:42.833Z"},{"id":"442ad10e-66b6-4287-97a1-26a63deac8a7","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-notify-node","type":"added","scope":"crm","summary":"Automations can now raise an in-app notification with a new \"Notify\" step.","body":"CRM automations gained a **Notify** step that raises an in-app notification\nthrough the notifications fabric. Set a title (and optional body + link); leave\nthe recipient blank to notify the workflow's owner, or template a user id (e.g.\n`{{ ownerId }}`) to notify someone specific. Like the email step it elevates\ninternally, so it works from manual, event, and scheduled runs alike. A new\n\"Notify owner on deal won\" template ships with it (gallery now 20). The\n`crm.workflow.notify` flow is registered in the notification preferences matrix\n(in-app by default) so operators can manage it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.305Z","updatedAt":"2026-06-13T14:09:42.305Z"},{"id":"95669758-e322-4729-8e9e-e74393078f29","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-invoices-list","type":"changed","scope":"web","summary":"The invoice create, email, record-payment, and void forms use the unified Form stack.","body":"Form-polish plan, Phase 5: all four forms on the invoices list page — create invoice\n(line-item editor), send-by-email composer, record-payment, and void-invoice — now use the\nunified `useAppForm` + `Form` stack with Zod validation and inline errors. The payment\namount→cents conversion, the `companyId` wire field + currency derivation, the shared\nline→payload mapping, the \"client + at least one valid line\" gate, and the void reason are\nall preserved; list filters, multi-select, and row/bulk actions are untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.320Z","updatedAt":"2026-06-13T14:09:42.320Z"},{"id":"d44edb85-fdbc-4455-a219-baec19fa5d99","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-invoices-detail","type":"changed","scope":"web","summary":"All ten invoice-detail forms (edit, payment, credit, refund, void, …) use the Form stack.","body":"Form-polish plan, Phase 5: every form on the invoice detail page — edit (line-item editor),\nrecord payment, issue credit note, refund payment, void, write-off, reject, share link,\nsend-by-email, and manage links — now uses the unified `useAppForm` + `Form` stack with Zod\nvalidation and inline errors. All three money flows (payment record, credit-note issue+apply,\nrefund + receipt) keep their exact cents arithmetic and multi-step sequences; line→payload\nconversion, validation gates, and email prefill are preserved. Header/row actions and the\nactivity panels are untouched. This completes the sales quotations + invoices form migration.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:39.611Z","updatedAt":"2026-06-13T14:37:39.611Z"},{"id":"d2de6733-49b2-47f8-a4d1-8a658d1cd352","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-webhook-trigger","type":"added","scope":"crm","summary":"Automations can now be triggered by an incoming webhook (POST with a secret token).","body":"CRM automations gained a fourth trigger type: **An incoming webhook**. Each\nwebhook workflow gets a unique URL (`/api/crm/workflow-webhook/<id>`) and a\ngenerated secret; an external system runs the automation by POSTing JSON with\nthe secret in the `X-Webhook-Token` header. The JSON body's fields become the\nrun's `{{ variables }}`. The secret is verified in constant time, the run\nexecutes under the workflow author's least-privilege context, and regenerating\nthe secret revokes the old one. The builder shows the URL + secret with\ncopy/regenerate controls. No migration — the secret lives in the trigger config.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.166Z","updatedAt":"2026-06-13T14:37:40.166Z"},{"id":"56b3e381-8fdd-40a0-8369-b2cc8abc4e2a","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-profile-card-and-pane","type":"changed","scope":"chat","summary":"Redesigned profile hover card and added a Slack-style right-rail profile pane.","body":"The user hover card that appears on chat avatars / mentions has been redesigned:\n\n- Accent banner at the top with the chat module colour, larger overlapping\n  avatar with a presence-coloured ring (in_huddle / typing / active / away /\n  offline).\n- Name + status row on the right.\n- Email row turned into a single-click copy affordance (hover reveals the\n  copy chip; tap copies and shows a brief \"Copied\" confirmation).\n- Two-column footer with **Message** (opens DM) and **View profile** (opens\n  the right-rail pane).\n\nClick on a chat avatar / mention now opens a **right-rail profile pane**\ninstead of pinning the hover card. The pane is mounted via a module-level\nevent bus (`openProfilePane({ userId })`) — any chat component can fire it\nwithout prop-drilling. The pane co-owns the right rail with the thread and\nAI panes; only one rail surface paints at a time, and the profile pane wins\nwhen open. Escape closes the pane.\n\nBoth surfaces share the same data source (`chat.user.search`, scoped to the\nactor's org) so avatars + emails appear consistently and presence colours\nmatch the sidebar / member popovers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.375Z","updatedAt":"2026-06-13T14:37:40.375Z"},{"id":"9ff5f643-ab8d-40f3-a1bd-ba2b2f99a16d","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"clients-open-client-chat","type":"added","scope":"clients","summary":"Clients detail page gains \"Open client chat\" CTA — opens the gated client-communications space for the client.","body":"Client Spaces Phase C4 — the first UI entry point for the gated\nclient-communications tier.\n\nThe clients detail page (`/clients/$id`) now shows an **Open client\nchat** button in the header action row alongside AI brief / Portal\naccess / Lifecycle. Clicking it:\n\n1. Calls `chat.client_space.find_or_create({ clientId })` — idempotent\n   (returns the existing space if one exists, creates one in a\n   transaction if not, with visibility forced to `secret` and\n   `kind='client'`).\n2. Toasts the result (created vs opened).\n3. Navigates to `/chat` so the user lands in the chat module — the\n   newly-created space will appear in their SpaceMenu switcher\n   immediately because `space.list` re-fetches on navigation.\n\nThe button is gated by `chat:client_space:create` OR `:read`. With the\naccess model from `28b30153`, this means owners + admins + any\noperator with an explicit grant (Settings → Users → Permissions or\na custom role like \"Account Manager\") see the button. Other users\nnever see it.\n\nOnce the per-space route shape (`/chat/$spaceSlug/$channelId`) lands\nin a follow-up commit, the navigation target becomes the specific\nspace landing rather than the inbox.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.540Z","updatedAt":"2026-06-13T14:09:42.540Z"},{"id":"d8e2bd2a-1909-492c-8b3a-715088225cb0","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-client-spaces-c6-mention-scope","type":"changed","scope":"chat","summary":"Mention picker scopes to space members in client rooms; @channel and @here are refused.","body":"Two tightening changes for client-kind chat spaces:\n\n**Mention picker (`chat.user.search`)** now accepts an optional `channelId`.\nWhen the channel lives in a `kind='client'` space, the candidate pool is\nrestricted to that space's members instead of the whole org. Client portal\nusers no longer see arbitrary employees in their @-picker, and internal staff\ncan't accidentally @-mention employees who aren't part of the client room. The\ncomposer (both Tiptap and plain) plumbs the active channelId through to the\naction.\n\n**Broadcast mention refusal (`chat.message.post`)** rejects `@channel` and\n`@here` mentions in any channel that lives in a client space, returning\n`validation_failed` with the offending kinds. A broadcast mention in a client\nroom would fan a single ping across the internal/external membership boundary;\nauthors must @-mention people individually instead. `@user` is unchanged.\n\nPlumbing: `loadChannelWithMembership` now returns `spaceKind` alongside the\nexisting fields, so callers (post, future archive/manage) can branch on\nclient-space semantics without a second query.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.364Z","updatedAt":"2026-06-13T14:37:40.364Z"},{"id":"2c7f5fd2-3be5-4888-9505-ed005c72a351","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-progressive-profiling","type":"added","scope":"forms","summary":"Forms support progressive profiling — known fields are hidden so returning visitors are only asked for new information.","body":"A field can be marked **known-only** (progressive). When the field already has a\nvalue — from URL/UTM capture, context prefill, or initial values — it's hidden\nand its known value still submits, so an identified or returning visitor is only\nasked for information you don't already have. Unknown (empty) fields still show,\nand the field's conditional rule is still respected. Combined with the existing\nprefill/capture, this is the engine half of known-visitor progressive\nprofiling; the host supplies identity (authenticated user, resolved prefill,\netc.) via context. Toggle it per field in the builder (\"known-only\").","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:31:32.230Z","updatedAt":"2026-06-15T20:31:32.230Z"},{"id":"ab185f55-bf13-4256-a04e-7f006840e31e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-scheduled-triggers","type":"added","scope":"crm","summary":"Automations can now run on a schedule (cron), e.g. every weekday at 09:00.","body":"CRM automations gained a third trigger type: **On a schedule**. Pick a preset\n(every day / weekdays / Mondays / first of the month / hourly / every 15 minutes)\nor enter any standard 5-field cron expression. A worker cron runs each minute and\nfires due schedules; runs are de-duplicated per matched minute so a schedule\nfires at most once even across worker restarts, and a missed minute is caught up\nwithin a short window. Schedules are evaluated in UTC. No migration — the\nschedule lives in the workflow's existing trigger config.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.565Z","updatedAt":"2026-06-13T14:09:42.565Z"},{"id":"ee0c4e1e-defc-462b-a8cc-b5b4755b4534","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-channel-space-aware","type":"changed","scope":"chat","summary":"chat.channel.create + chat.channel.list now accept spaceId; new channels resolve target space from input → last-visited → org default.","body":"Phase 1.B continued — wires channel actions to the spaces tier.\n\n- **`chat.channel.create`** gains an optional `spaceId` field. Target\n  resolution precedence: explicit input → actor's last-visited space\n  (`chat_space_members.last_visited_at` DESC) → org's default space\n  (`chat_spaces.is_default=true`). A non-member of the explicit target\n  gets `policy_denied`; an actor in no spaces with no org default gets\n  `not_found`. The new helper `resolveSpaceForActor` in\n  `modules/chat/src/lib/resolve-space.ts` owns this lookup so the\n  channel-update + future actions can reuse it.\n- **`chat.channel.list`** gains an optional `spaceId` filter. When\n  omitted (back-compat default), returns channels across every space\n  the actor is in. When supplied, narrows to that space. The sidebar's\n  per-space filter will use this once the switcher's route shape lands.\n\nDeferred to a follow-up:\n- **`chat.channel.update`** cross-space move — substantial enough to\n  warrant its own commit. Member reconciliation + system message on\n  move + dangerous-flag UX all sit on top of the same resolver helper.\n\n199 chat tests pass (existing channel-create test extended with a\nfakeDb that models the resolver's select chain).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.599Z","updatedAt":"2026-06-13T14:09:42.599Z"},{"id":"01909784-4dfe-4227-8da3-ad5d6b6c855f","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"branding-tier-pdf-letterhead-phase-h","type":"changed","scope":"hrm","summary":"HRM PDF letterhead (joining letter / contract / NDA) now respects the whitelabel plan tier — free + Starter orgs see the platform letterhead with their legal name preserved in the body; Business+ keeps the full org-branded letterhead.","body":"Phase L.4 Phase H of the signup-hardening initiative. Extends the\nthree-tier plan-gated branding contract to PDF letterhead.\n\n**What changed.**\n\n- New helper `resolveDocumentChrome(db, orgId)` in\n  `modules/saas/src/lib/document-chrome.ts` consults the\n  `whitelabel` plan flag (and the legacy `custom_branding` alias)\n  + reads the platform logo from `platform_settings`. Returns a\n  small decision object: `{ letterheadLogoOverride, sealAllowed,\n  signatureAllowed, watermarkAllowed, whitelabel }`.\n\n- `modules/hrm/src/lib/document-pdf-render.tsx` (the renderer for\n  joining letters, contracts, NDAs) consults this decision before\n  passing chrome to the React-PDF templates:\n  - Whitelabel granted → keeps the org's letterhead logo, seal,\n    signing-authority signature image, and per-org watermark\n    unchanged.\n  - Whitelabel absent → letterhead logo falls back to the platform\n    mark; seal + signature image + per-org watermark are\n    suppressed.\n\n- What stays org-owned in **every** mode (identity, not chrome):\n  legal name, registration number, tax ID, address, country,\n  signing-authority NAME + TITLE, support email, website URL.\n  The body of every contract / NDA / offer must legally name the\n  issuing party — that's identity, not brand.\n\n**Back-compat.** Existing tenants on a plan with `custom_branding:\ntrue` are treated as whitelabel-granted (most-permissive read).\nNothing changes for them.\n\n**Fail-soft.** A degraded plan-tables read OR a degraded platform-\nsettings read leaves the org with its own chrome. A broken DB\nnever blocks a PDF render.\n\n**Tests.** 5 pure-decider tests cover every branch (whitelabel\ngranted / legacy alias granted / free + Starter override /\nfail-closed nulls / platform logo absent → monogram fallback).\nAll 388 existing HRM tests still pass — the integration suite\nhits the renderer via the joining-letter / contract / NDA paths.\n\n**Scope this commit.** Only the HRM document renderer is gated.\nThe recruitment offer letter (`modules/recruitment/src/lib/offer-\npdf-render.tsx`), sales (invoice / quote / credit note), payroll\n(payslip / year-end), and HRM attendance report renderers still\nship unchanged — they're high-stakes customer-facing PDFs and\nneed visual QA on real rendered output across template + locale\ncombinations before they swap. Tracked in\n`docs/plans/BRANDING_WHITELABEL_TIERS_SPEC.md` §10 (Phase H\nsub-tasks).\n\nSpec: `docs/plans/BRANDING_WHITELABEL_TIERS_SPEC.md` §4 +\n\"generated documents\" surface row.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.434Z","updatedAt":"2026-06-13T14:37:40.434Z"},{"id":"9dbcf658-3568-4756-b7a9-31074e080b62","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-2c-submit-and-dedup","type":"added","scope":"roadmap","summary":"Added the gated \"Request a feature\" submit flow with pgvector at-submission dedup.","body":"Phase 2c of the platform Roadmap & Feature Request module — the\ncustomer-facing submit surface + pgvector at-submission dedup. This\ncloses Phase 2 of the spec; the customer-facing MVP is now complete.\n\n**Two new actions** (both gated by `roadmap:feature:submit` —\nowner / admin / manager standard role blueprints + root):\n\n- `platform.roadmap.feature.find_similar` — pgvector cosine lookup over\n  title + summary embeddings. Returns the top-N matches above the\n  threshold from `platform_roadmap_settings.dedup_similarity_threshold`\n  (default 0.85). Called as the user types in the submit form\n  (debounced 400 ms) so the matches surface inline with \"vote instead?\"\n  CTAs.\n- `platform.roadmap.feature.submit` — inserts the row + embedding +\n  auto-vote + auto-follow. Runs the dedup safety net unless the caller\n  passes `acknowledgeDuplicate: true`; a match ≥ threshold without\n  acknowledgement returns the structured `duplicate_likely` error with\n  the matched feature ids so the form can re-render the\n  \"this looks like #N — submit anyway?\" dialog. Emits\n  `platform.roadmap.feature.created` with `source: 'user_submission'`.\n\nBoth actions degrade cleanly when no embedding provider is configured —\n`find_similar` returns `embeddingDisabled: true`; `submit` skips the\ndedup check + the embedding column write but still inserts the row.\n\n**New UI** at `/help/roadmap/new`:\n\n- Full-page submission form (title / summary / body / category /\n  visibility) with a live similarity panel that surfaces top-3 matches\n  as the user types.\n- Permission-gated: unauthenticated visitors see a \"Sign in to submit\"\n  CTA; authenticated employees + clients (lacking the perm) see a\n  banner explaining that submissions come from their org's leadership.\n- Acknowledge-duplicate confirmation dialog on the safety-net path.\n- On success, navigates to `/help/roadmap/<slug>` with a toast.\n\n**Public roadmap page updates** (`/help/roadmap`):\n\n- \"Request a feature\" CTA in the header — rendered ONLY for actors\n  with `roadmap:feature:submit`, hidden otherwise (not greyed — per\n  spec §D11).\n- Authenticated actors lacking the permission see a soft amber banner\n  explaining how to file via their org leadership.\n\n**Tests:** 9 new vitest cases on `submit` + `find_similar` covering\npolicy denial, validation failures (title length, invalid category /\nvisibility, threshold out of range), the embeddingDisabled fallback\npath, and event emission shape. **36 / 36 module tests pass.**\n\n`@helios/ai` added as a workspace dep on `@helios/roadmap`. The\nembedding resolution falls through `setRoadmapEmbeddingProvider()`\n(test stash) → `getGlobalEmbeddingProvider()` → null.\n\nPhase 2 of the platform roadmap is now complete. Next: Phase 3\n(comments + email notifications on `feature.status_changed`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.575Z","updatedAt":"2026-06-13T14:37:40.575Z"},{"id":"a2bd34a2-5e02-4511-9d3b-824c39752fe0","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-sidebar-space-aware-nav","type":"changed","scope":"chat","summary":"Sidebar channel links navigate straight to /chat/$spaceSlug/$channelId — no redirect hop.","body":"The chat sidebar's channel rows used to point at the legacy\n`/chat/$channelId` route, which then `beforeLoad`-redirected to the canonical\n`/chat/$spaceSlug/$channelId` shape via `chat.channel.resolve_route`. The\nredirect took roughly one round-trip — barely perceptible but a real hop on\nslow connections.\n\nEach sidebar row now resolves the channel's space slug locally from the\nalready-cached `chat.space.list` data and renders the Link with the\nspace-aware target directly:\n\n- Channels with a `spaceId` → that space's slug.\n- DMs / group DMs **pinned** to a space (per-user hybrid pin) → the pinned\n  slug, so the URL reflects where the user filed the conversation.\n- DMs / group DMs **unpinned** → the actor's current active space slug, so\n  the URL matches the sidebar context they clicked from.\n- Unresolved (cold cache, missing space row) → the legacy\n  `/chat/$channelId` route, which still redirects via the existing\n  `chat.channel.resolve_route` action. Links never break; the redirect is\n  the safety net.\n\nNet effect: zero redirect hops for the common path; the legacy route remains\nthe safe back-compat target for every other surface (mentions, search, deep\nlinks, hover card → \"Send DM\", external notifications). Those migrate\nincrementally.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.240Z","updatedAt":"2026-06-15T15:59:11.240Z"},{"id":"6a407f55-7690-403e-af6d-9de7319d5f59","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-menu-i18n-a11y","type":"changed","scope":"chat","summary":"SpaceMenu strings + tooltips + indicators are now translatable and screen-reader-friendly.","body":"Polish pass on the chat space switcher (added this session) for i18n + a11y\ncoverage:\n\n**i18n**: every visible string in `space-menu.tsx` is now wrapped through\n`tt(...)` — empty / search-no-match labels, the \"Your spaces\" and\n\"Browse to join\" section headers, the \"Create a new space\" footer button,\nthe trigger's aria-label, the search input placeholder, the per-row\n\"Default space\" + \"Client space\" tooltips, and the \"activity elsewhere\"\ntooltip on the trigger dot. Counts in the elsewhere tooltip use the\ncodebase's existing `{count}` interpolation pattern.\n\n**a11y**: the \"activity elsewhere\" dot on the trigger picked up a\n`role=\"status\"` plus an `aria-label` that reads the count (was\n`aria-hidden`, so screen reader users had no signal). The default-space pin\nglyph and client-space pill picked up `aria-label` attributes tied to their\ntooltip content. The pin glyph keeps `aria-hidden` on the inner icon\nbecause the wrapping span carries the label.\n\nNo behaviour changes; locale-aware deployments now translate the switcher\ncorrectly and assistive tech announces the elsewhere-activity hint.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.542Z","updatedAt":"2026-06-15T15:59:11.542Z"},{"id":"985b02ff-767a-4a49-9dee-ad8775449415","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-6a-merge","type":"added","scope":"roadmap","summary":"Added the duplicate-merge action — votes / followers / comments migrate idempotently; source URL 301s to the target.","body":"Phase 6a of the platform Roadmap & Feature Request module — duplicate\nmerge. The recurring operational pain point (every Canny / Featurebase\nadmin hits it weekly): customers file the same ask in three different\nshapes and the votes scatter.\n\n**New action: `platform.roadmap.feature.merge`**\n\nSingle-transaction operation. Root-only\n(`platform:roadmap:manage`) + `dangerous: true`:\n\n1. **Votes migrate** — moves rows where the user doesn't already have\n   a vote on the target; deletes the rest. Idempotent against the\n   `(feature, user)` unique index — no double-counting even when both\n   features had overlapping voters.\n2. **Followers migrate** — same idempotency shape.\n3. **Comments reparent** — no unique to honor, just `UPDATE … SET\n   feature_id = target` for non-deleted rows.\n4. **Source is soft-deleted** with `meta.merged_into = target.id`. The\n   public read action (`feature.get_public` Phase 2a) already walks\n   `meta.merged_into` on read so old URLs serve the canonical row with\n   a \"redirected from\" hint instead of a 404.\n5. **Denorm counts recompute from row counts on the target** — drift-\n   proof. Even if the source had stale counts, the target lands on the\n   truth.\n6. **Audit row** in `platform_roadmap_merges` with the migrated counts\n   and the operator's free-text reason.\n7. Emits `platform.roadmap.feature.merged`.\n\n**Email subscribers intentionally do NOT fire on merge.** Followers of\nthe source feature were migrated — they keep their follow on the\ntarget — and mass-notifying every voter of a duplicate consolidation\nis the noisy-Canny pattern we locked out (spec §13).\n\n**Admin UI** on the edit-feature sheet at `/saas/roadmap`: a new\n\"Merge into another feature\" block with a target-UUID input, an\noptional reason field, and a confirm-before-merge dialog. The confirm\nmessage names the source title + target id so operators don't merge\nthe wrong one. Success toast surfaces the migrated counts:\n`Merged. Migrated 9 votes, 7 followers, 2 comments. Target now has 24 votes.`\n\n**Schemas:**\n\n- `MergeFeatureInput` — `{ sourceId, targetId, reason? }` with a Zod\n  `.refine` rejecting self-merge.\n- `MergeFeatureOutput` — `{ mergeId, sourceId, targetId,\n  targetVoteCount, targetFollowerCount, targetCommentCount,\n  votesMigrated, followersMigrated, commentsMigrated }`.\n\n**Tests:** 4 new vitest cases on `merge.test.ts` — policy denial\n(no manage perm), validation failures (self-merge, invalid uuid),\n`not_found` when source is missing. **57 / 57 module tests pass.**\n\nThe happy-path + cross-row consistency tests (denorm recompute,\nidempotent vote migration, audit + event emit) belong in the PGlite\nintegration suite — the merge handler uses raw `db.execute()` for the\n`NOT EXISTS` conditional UPDATE that `fakeDb` doesn't model.\n\nPhase 6b next: `vote.cast_on_behalf` for sales / CSM workflows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.577Z","updatedAt":"2026-06-15T15:59:11.577Z"},{"id":"556f4ae0-9694-4821-9fcc-818e2b07f33c","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-1a-admin-actions","type":"added","scope":"roadmap","summary":"Added the admin action layer for the platform roadmap module — feature CRUD + lifecycle + settings.","body":"Phase 1a of the platform Roadmap & Feature Request module — the action\nlayer. Ten actions, all root-only:\n\n- `platform.roadmap.feature.create` — admin authoring; bypasses dedup\n  (admins are trusted); no auto-vote (operator is staging, not\n  expressing personal demand). Emits `feature.created` with\n  `source='admin_authoring'`.\n- `platform.roadmap.feature.update` — metadata edit (title / summary /\n  body / category / tags / target / visibility / external tracker).\n  Emits `feature.updated` with the changed-field list.\n- `platform.roadmap.feature.delete` — soft-delete (`dangerous: true`).\n  Admins normally use merge (Phase 6) instead.\n- `platform.roadmap.feature.set_status` — lifecycle ladder\n  (open → under_review → planned → in_progress → shipped, or\n  terminal declined). Declining requires `resolutionNotes`. Flipping\n  to shipped stamps `shipped_at`. Emits `feature.status_changed`.\n- `platform.roadmap.feature.promote_to_roadmap` /\n  `platform.roadmap.feature.demote_from_roadmap` — flip\n  `roadmap_visible` without changing status. Emits\n  `feature.roadmap_visibility_changed`.\n- `platform.roadmap.feature.list_admin` — triage queue with status /\n  category filters, free-text search, five sort options\n  (trending / vote_count / newest / oldest / recently_updated),\n  pagination + total count.\n- `platform.roadmap.feature.get_admin` — single-feature read by id or\n  slug; includes soft-deleted rows.\n- `platform.roadmap.settings.get` / `.update` — singleton config row\n  (hero copy, dedup threshold, public-read kill switch, vote plan\n  gate, visible categories).\n\nSix new events: `feature.{created,updated,deleted,status_changed,\nroadmap_visibility_changed}` + `settings.updated`. Wired into the\noRPC + MCP registry via the standard `import '@helios/roadmap/actions'`\nside-effect pattern in `apps/web/src/server/api.ts` and\n`apps/worker/src/index.ts`.\n\nTest coverage: 11 unit tests across create / set_status / list_admin\ncovering happy path + policy denial + validation failures (incl. the\ndeclined-without-reason guard). DB faked via `@helios/testing`.\n\nNo UI yet — Phase 1b adds the `/saas/roadmap` admin route (Triage +\nRoadmap kanban + Settings tabs).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.804Z","updatedAt":"2026-06-13T14:09:42.804Z"},{"id":"ce811553-5b01-477b-bd2e-1844e89ecdc3","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-1b-admin-ui","type":"added","scope":"web","summary":"Added the platform roadmap admin console at /saas/roadmap — Triage, Roadmap kanban, and Settings tabs.","body":"Phase 1b of the platform Roadmap & Feature Request module — the\nroot-only admin UI at `/saas/roadmap`. Three tabs:\n\n- **Triage** — filterable, sortable queue of every feature including\n  drafts, declined, and (toggle) soft-deleted. Filter chips for\n  status + category, free-text search over title/summary, five sort\n  options (trending / top all-time / newest / oldest / recently\n  updated). Inline row actions: edit (opens detail Sheet), mark\n  shipped, promote/demote from public roadmap, soft-delete. New\n  feature CTA opens an authoring Sheet.\n- **Roadmap** — three-column kanban (Planned / In Progress / Shipped)\n  showing the curated subset where `roadmap_visible = true`. Mirrors\n  what customers will see at `/help/roadmap` (Phase 2) and the\n  marketing `{apex}/roadmap` (Phase 4). Read-only for now; promotion\n  + status flips happen on the Triage tab.\n- **Settings** — singleton config form. Hero copy + disclaimer for\n  the marketing page, dedup similarity threshold (0–1, default\n  0.85), public-read kill switch.\n\nSub-nav entry added to `SAAS_MODULE.subNav` in the Communications\ngroup alongside Announcements, Changelog, and Email — a surgical\none-line addition.\n\nThe detail Sheet exposes the full lifecycle ladder as buttons:\nclicking → declined prompts for a customer-visible reason; → shipped\nstamps `shipped_at`. The form on the edit sheet covers title /\nsummary / body / category / visibility / target label and calls\n`feature.update`; status flips call `feature.set_status` separately.\n\nAll calls go through the Phase 1a actions\n(`platform.roadmap.feature.*` + `platform.roadmap.settings.*`).\nPhase 2 ships the customer-facing `/help/roadmap` board + vote /\nfollow / submit flow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:43.247Z","updatedAt":"2026-06-13T14:09:43.247Z"},{"id":"3385d609-fbbe-4a95-97d4-cf03120a6ed9","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-sidebar-dead-ref-handling","type":"fixed","scope":"chat","summary":"Sidebar gracefully handles a stale active-space or DM pin that points at a space the user has left.","body":"Closes two edge-case bugs and lands one perf win along the way.\n\n**Dead active-space.** When `persistedActiveSpaceId` in localStorage pointed at\na space the user had been removed from (admin action, space deleted, etc.),\nthe sidebar filter would clamp to that phantom space and render as empty.\nThe sidebar now watches the spaces query — when the persisted id isn't in\nthe user's current member-space set, it's silently cleared (both the\n`useState` and the localStorage row) so the back-compat \"show everything\"\ndefault takes over until the user picks again.\n\n**Dead DM pin.** When a user pinned a DM to Space X and was then removed\nfrom Space X, the DM's `pinnedToSpaceId` would never match any visible\nspace and the DM disappeared from every sidebar view — invisible forever\nuntil the pin was cleared via the action API. The `inActiveSpace` filter\nnow treats a pin pointing at a non-member space as if no pin existed (the\nDM falls back to its org-wide default visibility), so the conversation\nstays reachable.\n\n**Perf**: the spaces query (`chat.space.list`) had been mounted PER\n`ChannelRowItem`. A user with 200 channels paid 200 React subscriptions\nkeyed off the same cache entry. The query is now hoisted to the sidebar\nparent and the resulting `spaceSlugById` / `memberSpaceIds` /\n`defaultSpaceSlug` / `pinnableSpaces` derivations flow down as props. One\nsubscription. The space-aware nav target + DM-pin submenu read from the\nhoisted maps with O(1) lookups instead of recomputing them per row.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.243Z","updatedAt":"2026-06-15T15:59:11.243Z"},{"id":"31dfc49e-944a-4a7b-8ad4-7faf625d4215","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-use-case-and-tags","type":"added","scope":"roadmap","summary":"Added \"What are you trying to accomplish?\" + tag chip input to the feature request form — research-driven additions, all anti-pattern fields skipped.","body":"User-requested ticket-like fields, but driven by a 5-product research\nworkflow (Canny, Productboard, Linear, Featurebase, Frill/Sleekplan/\nUserVoice) + a 3-lens judge panel (conversion / triage / spam).\n\n**The one new field that survives every lens: `use_case`.**\n\nThe single most-cited high-value submitter field across all five\nproducts. Optional free text answering \"What are you trying to\naccomplish?\" — captures the job-to-be-done so admin triage routes by\nintent rather than surface words.\n\n- New `use_case text` column on `platform_roadmap_features` (nullable,\n  default null, migration `0258_0259_roadmap_use_case`).\n- Threaded through `SubmitFeatureInput`, `CreateFeatureInput`,\n  `UpdateFeatureInput`, `FeatureDto`, `FeatureAdminDto`,\n  `FeaturePublicDto`, every handler, and the public-DTO mapper.\n- Form field surfaces RIGHT AFTER the summary (Featurebase pattern —\n  asked after the commit point), with neutral labelling. Placeholder\n  shows a concrete example so submitters know what good looks like.\n\n**Tags chip input on the submission form.**\n\nThe schema has supported tags since Phase 0; the submitter form just\nnever exposed them. Now there's a proper chip multi-input — type +\nEnter, slugifies to kebab-case automatically, backspace removes the\nlast chip, hard-capped at 10 tags per request to match the action-\nlayer Zod limit. Optional. Renders below visibility.\n\n**Field labels rephrased for the neutral-prompt research finding.**\n\nCanny's stated philosophy: prescriptive sub-prompts (\"Problem:\",\n\"Solution:\", \"Why:\") depress submission rates vs a neutral \"Details\"\ntextarea. Rename \"Body (optional)\" → \"Details (optional)\" on every\nform (public + admin).\n\n**Anti-patterns the research surfaced — explicitly NOT shipping:**\n\nPer the universal verdicts across the 3-lens judge panel:\n\n- `priority` / `severity` / `urgency` — universal anti-pattern;\n  \"everyone marks Critical\". Priority is an OUTPUT of vote count, not\n  an INPUT. Helios is upvote-only — votes ARE the priority signal.\n- `request_type` (bug/feature/improvement) — Linear and Featurebase\n  explicitly reject. Type is implicit in the surface (roadmap = feature\n  requests; bugs go to Support).\n- `target_date` / `when_needed_by` — hostage answers (\"yesterday\").\n- `affected_user_count` / `business_impact` — gameable + cheaply faked.\n- `target_users` / `department` — leaks internal taxonomy; admins should\n  derive from authenticated identity.\n- `supersedes_request_id` — replaced by inline pgvector similar-posts\n  panel (already shipped in Phase 2c).\n- `steps to reproduce` — bug-tracker scaffolding; not for a roadmap.\n\n**Detail page updates.**\n\nWhen `use_case` is present, renders a callout block above \"Details\"\nwith accent-tinted border + the \"What they're trying to accomplish\"\nheader so triage admins reading the page can spot the intent without\nre-reading the body.\n\n**Admin sheets** (`/saas/roadmap` New feature + Edit) — both get\nthe `use_case` textarea between Summary and Details, matching the\npublic-form ordering.\n\n**Workflow / research artifacts.**\n\n5 deep product researches + 3 lens-verdicts captured via a fan-out\nworkflow (10 agents, 946K tokens). The unanimous verdicts on what to\ninclude vs omit are reflected in field choices above. The original\nspec §11 prescribed-fields list overrides the conservative spec\ndefaults (which had only 6 fields) per direct user request for\n\"ticket-like options\" — but the additions are research-validated, not\njust expansive.\n\nTests: existing 57 / 57 module tests pass after the schema extension\n(backward-compatible — existing rows default to `use_case = NULL`).\nTypechecks clean across `@helios/roadmap` and `apps/web`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.644Z","updatedAt":"2026-06-15T15:59:11.644Z"},{"id":"088cd8d7-8633-4010-a952-9dfd2d409c02","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-list-sla-field-symmetry","type":"changed","scope":"support","summary":"Ticket list query now exposes all three SLA due timestamps, matching the detail view.","body":"`support.ticket.list` rows now include `nextResponseDueAt` alongside the\nexisting `firstResponseDueAt` / `resolutionDueAt`, so the list and detail\nshapes carry the same SLA due fields. No user-visible change today — all\nthree are Phase-4 SLA placeholders until the writer lands — but it removes\na list/detail asymmetry for consumers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.965Z","updatedAt":"2026-06-15T15:59:11.965Z"},{"id":"80d6c5bb-65cf-476c-8b2a-d6621f991a6a","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-client-spaces-c4-part2","type":"added","scope":"chat","summary":"New-space modal can create client spaces; space switcher shows a Client kind pill.","body":"The \"+ Create space\" modal in the chat sidebar now offers a Team/Client kind\ntoggle. Picking **Client** asks for the customer org and creates a gated,\nsecret-visibility space tied to that account via `chat.client_space.find_or_create`\n(idempotent) instead of `chat.space.create`. Visibility is locked to secret while\nthe kind is client (CHECK constraint).\n\nThe space switcher dropdown renders a subtle \"Client\" pill next to client-kind\nspaces so members can distinguish team workspaces from customer rooms at a glance.\nThe pill carries an explanatory tooltip.\n\nBehind the surface, `SpaceRow` now carries a `kind` field (`team` | `client`,\ndefault `team`) end-to-end from `chat.space.list` / `chat.space.get` through the\nsidebar.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.825Z","updatedAt":"2026-06-13T14:09:42.825Z"},{"id":"fea9e076-feaa-4b79-92e1-caaf98df7d38","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-calendar-sla-due","type":"added","scope":"support","summary":"Inbox Calendar can plot tickets by SLA first-response or resolution due date.","body":"The inbox Calendar view gains a date-basis toggle: **Filed** (the\nexisting behaviour, by creation date), **Response due**, or **Resolution\ndue**. On a due basis, tickets are placed on the day their SLA target\nfalls, tickets without that target drop off the grid, and past-due\ntickets that aren't closed/resolved render with a danger tint so missed\ndeadlines stand out. The ticket list query now surfaces\n`firstResponseDueAt` / `resolutionDueAt` (already computed by the SLA\nengine) to power this.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.660Z","updatedAt":"2026-06-15T15:59:11.660Z"},{"id":"6aedd495-3789-4229-87c2-2823098ace24","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-create-link-item","type":"added","scope":"support","summary":"The new-ticket sheet can link the ticket to a client-owned item at creation.","body":"The New ticket drawer gains an optional **Related item** section: pick an\nitem type (subscription / invoice / project / product / engagement), a\nrelationship (about / issue with / request for / related), and the\nspecific record by name. The link is created with the ticket, so a ticket\nfiled \"about subscription Y\" shows up on that subscription's Support panel\nimmediately. The entity picker is now a shared `<LinkTargetPicker>` reused\nby both the ticket detail \"Linked items\" block and this sheet.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.839Z","updatedAt":"2026-06-15T15:59:11.839Z"},{"id":"ed044ffa-393c-45fd-9be7-0354f95286a5","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-service-field-keys","type":"fixed","scope":"support","summary":"Service-catalog editor no longer lets fields share a key, which silently dropped data.","body":"In the support service-catalog editor (Settings → Support → Services), the\n\"Add field\" default key (`field_N`) could repeat after an add/remove/add\nsequence, producing two fields with the same key — and because the key is\nthe form payload identifier, the colliding field silently dropped the\nrequester's data. The default now picks the next unused `field_N`, and the\neditor blocks saving while any field key is empty or duplicated, with an\ninline message explaining which.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:12.134Z","updatedAt":"2026-06-15T15:59:12.134Z"},{"id":"3fcf75da-80c1-493f-875e-3fbce1c1a794","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-tickets-for-entity","type":"added","scope":"support","summary":"Entity detail pages can show the support tickets linked to that item.","body":"Adds `support.ticket.for_entity` — the reverse of the ticket-links query:\ngiven a client-owned entity (project / subscription / invoice / product /\nengagement), it lists the support tickets linked to it, newest activity\nfirst, with status + priority for rendering. A reusable\n`<EntityTicketsPanel>` widget consumes it and is wired into the engagement\ndetail page's sidebar, so an agent viewing a client engagement sees its\nopen support threads at a glance and can jump straight to a ticket. The\npanel stays quiet when the viewer lacks `support:ticket:read`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:12.178Z","updatedAt":"2026-06-15T15:59:12.178Z"},{"id":"7376f933-50ba-4067-8ebc-342bc4de7a75","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-invite","type":"added","scope":"chat","summary":"chat.space.invite — invite users to a chat space by email; existing org members added directly, others go through iam.invitation.create.","body":"Closes Phase 1.B's last named action gap. The spec calls for folding\ninvites into `chat:space:manage_members` per D7 — separate\n`chat:space:invite` would create a \"can invite but can't remove\"\nfootgun.\n\nBehaviour per email:\n\n- **Existing org member, not yet a space member** → direct add to\n  `chat_space_members` (`status: 'added'`).\n- **Existing org member, already in space** → no-op\n  (`status: 'already_member'`).\n- **Pending invitation already exists for this email** → no-op\n  (`status: 'existing_invite'`).\n- **New external email** → calls `iam.invitation.create` with the\n  org role 'member' and a 7-day expiry (`status: 'invited'`).\n- **External + the space's `external_invites_allowed` is false** →\n  refused with a reason string (`status: 'refused'`).\n\nIdempotent across the batch: same email twice in one call resolves\nonce. Per-email loop is small but uses three batched lookups\nupfront (`memberships`, `chat_space_members`, `invitations`) to keep\nthe per-email overhead bounded.\n\nRequires `chat:space:manage_members` AND space-admin role (or\n`chat:admin` umbrella). Handler refuses non-admin actors even with\nthe permission key, mirroring the other member-management actions.\n\n5 test cases land: policy denial · not_found on missing space ·\nnon-admin denial · validation rejection of empty email list · rejection\nof malformed email. 199 chat tests pass total.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:09:42.826Z","updatedAt":"2026-06-13T14:09:42.826Z"},{"id":"ac02d2c1-8ace-4f73-a962-eac7206b752e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-branch-editing","type":"added","scope":"crm","summary":"Branch (router) steps are now editable in the automation builder.","body":"Branch steps — which route a workflow down the first path whose conditions match\n— can now be added and edited directly in the builder. Previously they were\npreserved but read-only (authored only via templates). The config panel exposes\nthe branch's paths as a structured, validated editor (each path has a label, a\nmatch mode, a conditions array, and its own steps); a fresh branch starts from a\nsensible one-path + catch-all skeleton. Invalid structures are caught on save by\nthe same workflow schema the engine uses. (A drag-and-drop lanes view on the\ncanvas remains a future visual enhancement.)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.177Z","updatedAt":"2026-06-13T14:37:40.177Z"},{"id":"413c963c-2f67-4a46-a728-481540814781","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ticket-linked-items-ui","type":"added","scope":"support","summary":"The ticket detail sidebar can now show and manage the client items a ticket is linked to.","body":"Agents can now see, add, and remove the client-owned items a support\nticket is linked to (project, subscription, invoice, product, or\nengagement) from a new \"Linked items\" block in the ticket sidebar, backed\nby the `support.ticket.link` / `unlink` / `links` actions. The block shows\nthe entity type and relationship; resolving each entity's name via\ncross-module reads — and a searchable picker in place of pasting an id —\nare follow-ups. See docs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.624Z","updatedAt":"2026-06-13T14:37:40.624Z"},{"id":"e3e277f4-b472-4c03-9849-5063ed1994c9","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-six-columns-upvote-icon","type":"changed","scope":"roadmap","summary":"Public roadmap kanban now shows all six statuses + the vote icon is an industry-standard upvote arrow with a prominent count tile.","body":"User-requested. Two visible improvements on every roadmap surface:\n\n**Six-column public kanban (overrides locked spec §11).**\n\nThe marketing `{apex}/roadmap` and the in-app `/help/roadmap` Roadmap\ntab now render all six lifecycle statuses as columns:\n\n- **Open** — fresh submissions\n- **Under review** — admin triage in progress\n- **Planned** — committed to the queue\n- **In progress** — engineering picked it up\n- **Shipped** — landed (newest first by `shippedAt`)\n- **Declined** — terminal-with-reason (newest first by `updatedAt`)\n\nSpec §11 originally locked the public surface to three columns\n(Planned / In Progress / Shipped) per the Canny / Featurebase\nindustry pattern. User overrides that decision — wants full pipeline\nvisibility for the customer. Honoured.\n\nGrid is responsive: `grid-cols-1 sm:grid-cols-2 lg:grid-cols-3\nxl:grid-cols-6` so the six columns lay out cleanly on desktop and\ncollapse to a stack on mobile.\n\n`boardSummaryPublic` action now returns `{ open, underReview,\nplanned, inProgress, shipped, declined }` (was 3 buckets). The row\nbudget bumped from 200 → 500 so terminal columns can carry history.\n\n**Industry-standard upvote icon + prominent count.**\n\nReplaced the previous `Sparkle ★` chip-with-count with:\n\n- A **vertical upvote tile** on every feature card — square box with\n  the Phosphor `ArrowFatUp` arrow above a tabular-nums count. Pattern\n  cribbed from ProductHunt / Canny / StackOverflow.\n- On the detail-page sidebar, the Vote button now uses `ArrowFatUp`\n  with the label \"Upvote\" / \"Upvoted\" (was \"Vote\" / \"Voted\") to match\n  the customer's mental model.\n- The \"Sign in to vote\" CTA is now \"Sign in to upvote\".\n\nVoting stays **upvote-only** (locked §10) — no downvotes. Downvoting\nbecomes a hostility vector against other submitters (a separate\nlocked decision; see the Canny / Featurebase rationale in the spec).\n\n**Schema:** `BoardSummaryPublicOutput` adds `open`, `underReview`,\n`declined` fields. Marketing `PlatformRoadmapBoard` type mirrored\nin `cms-runtime.ts`. Existing 57 / 57 module tests still pass after\nextending the kanban property assertions. Typechecks clean across\n`@helios/roadmap`, `apps/web`, `apps/marketing`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.667Z","updatedAt":"2026-06-15T15:59:11.667Z"},{"id":"6381471d-f89d-468f-a089-f694d44f774b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"branding-tier-pdf-recruitment-phase-h3","type":"changed","scope":"recruitment","summary":"Recruitment offer-letter PDF now respects the whitelabel plan tier — free + Starter orgs see the platform letterhead with their legal name preserved in the body; Business+ keeps the full org-branded letterhead.","body":"Phase L.4 Phase H.3 of the signup-hardening initiative. Extends\nthe L.4 plan-gated branding contract to the recruitment offer-\nletter PDF, reusing the `resolveDocumentChrome(db, orgId)`\nhelper landed in Phase H\n([[branding-tier-pdf-letterhead-phase-h]]).\n\n**What changed.**\n\n- `modules/recruitment/src/lib/offer-pdf-render.tsx` now consults\n  `resolveDocumentChrome` before passing chrome to the\n  `OfferLetterPdf` template:\n  - Whitelabel granted → keeps the org's letterhead logo,\n    fallback wordmark logo, signing-authority signature image,\n    and seal unchanged.\n  - Whitelabel absent → letterhead logo falls back to the\n    platform mark; signature image + seal are suppressed.\n\n- Identity always preserved (legal name, registration number,\n  tax ID, address, signing-authority NAME + TITLE, support\n  email, website URL). The offer body must legally name the\n  issuing party even when chrome falls back to platform.\n\n- `@helios/saas` added as a dep on `@helios/recruitment` so the\n  module can reach the shared helper without going through the\n  action registry. Matches the same dep shape used by\n  `@helios/hrm` in Phase H.2.\n\n**Back-compat + fail-soft.** Same posture as H.2 — legacy\n`custom_branding: true` grants whitelabel via the alias; a\ndegraded plan-tables read leaves the org with its own chrome.\n\n**Tests.** All 184 recruitment tests pass — the offer-letter\nrender path is integration-tested via the `offer.send` flow,\nwhich exercises the renderer end-to-end.\n\n**Scope.** Recruitment offer letter is the second of the seven\nPDF renderers covered. Still pending:\n- Sales (`modules/sales/src/lib/*-pdf-render.ts`): invoice,\n  quote, credit note — high-stakes customer-facing money\n  documents. Visual QA on rendered output required first.\n- Payroll (`modules/payroll/src/lib/payslip-pdf-render.tsx`,\n  `actions/year-end.tsx`): payslip + W-2 / 1099 equivalents.\n- HRM attendance report (`modules/hrm/src/actions/\n  attendance-report-pdf.tsx`).\n\nSpec: `docs/plans/BRANDING_WHITELABEL_TIERS_SPEC.md` §4 +\n[[branding-tier-pdf-letterhead-phase-h]] tracking entry.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.418Z","updatedAt":"2026-06-13T14:37:40.418Z"},{"id":"998a3e1e-6124-4060-bd04-8900e67db9c7","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ticket-create-links","type":"added","scope":"support","summary":"A ticket can be created with its client-entity links in one call.","body":"`support.ticket.create` now accepts an optional `links[]` array, so a\nticket can be filed already linked to the relevant project, subscription,\ninvoice, product, or engagement — no separate `support.ticket.link` call.\nThe targets use the same validated registry; inserts are idempotent. See\ndocs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.450Z","updatedAt":"2026-06-13T15:23:25.450Z"},{"id":"4c875f7b-1141-44ab-b492-09beb705268f","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ticket-form-detail-polish","type":"changed","scope":"support","summary":"The ticket creation form can route to a group and add tags; the detail page can manage tags inline.","body":"The \"New ticket\" form now lets an agent assign the ticket to a support\ngroup and add tags at creation time (alongside the existing subject,\ndescription, requester, priority, and type). On the ticket detail page,\nthe sidebar's Tags block is now editable — agents can add and remove tags\ninline (previously read-only). See\ndocs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.707Z","updatedAt":"2026-06-13T15:23:25.707Z"},{"id":"2d0390ba-b5c0-4015-98f3-db98dc24476b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"sales-compact-resolvers","type":"added","scope":"sales","summary":"Subscriptions, invoices, and products can now render as cross-module link badges.","body":"Added `sales.subscription.get_compact`, `sales.invoice.get_compact`, and\n`sales.product.get_compact` — the ADR 0013 compact resolvers that return\nthe shared `CompactRecord` shape so other surfaces (e.g. a support ticket's\nlinked items) can render a name + status badge for these entities instead\nof a raw id. See docs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.958Z","updatedAt":"2026-06-13T15:23:25.958Z"},{"id":"76bd4258-52cb-45de-b5ce-30326eebad50","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-detail-assignee-and-polish","type":"changed","scope":"support","summary":"Ticket detail page can now (re)assign the agent, and polishes the sidebar.","body":"The ticket detail sidebar previously showed the assigned agent as\nread-only and only let you change the group, even though the assign action\nand the inbox board already support agent reassignment. It now has an\nagent picker (the shared MemberPicker over org members, with unassign),\nalongside the existing group selector. The Linked items block shows a\nloading skeleton instead of briefly flashing \"No linked items\" while it\nfetches, and the public/internal reply toggle gained accessible\n`aria-pressed` state, a group label, and focus rings (keeping the amber\n\"internal note\" cue).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.896Z","updatedAt":"2026-06-15T15:59:11.896Z"},{"id":"4cf9d8a9-08bd-4a07-8b2a-317aff05b655","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-2b-vote-follow","type":"added","scope":"roadmap","summary":"Added vote + follow actions and the interactive sidebar on /help/roadmap/<slug>.","body":"Phase 2b of the platform Roadmap & Feature Request module — voting +\nfollow + the live signal sidebar.\n\n**Two new actions** (session-only, no special permission):\n\n- `platform.roadmap.vote.toggle` — idempotent vote toggle. Casting on\n  the first call inserts a row in `platform_roadmap_votes`; a second\n  call removes it. The `(feature, user)` unique constraint prevents\n  double-votes even under concurrent clicks. On the first vote the\n  action also auto-inserts a follower row, so the voter receives the\n  shipped-state notification by default (Phase 3 wires the email\n  subscriber). Updates the denorm `vote_count` + `follower_count`.\n  Emits `platform.roadmap.feature.voted` with `action: 'vote' | 'unvote'`.\n- `platform.roadmap.follow.toggle` — independent follow toggle for\n  the \"watch without expressing demand\" UX. Unfollowing does NOT\n  unvote (spec §10 — voting = \"I want this\", following = \"tell me\n  when it ships\"). Updates the denorm `follower_count`. Emits\n  `platform.roadmap.feature.followed` with `action: 'follow' | 'unfollow'`.\n\n**Two new events:** `feature.voted` + `feature.followed`. Notifications\n+ trending-score subscribers consume these in Phase 3+.\n\n**New session policy** (`roadmapSessionPolicy`) — any authenticated\nuser passes; anonymous actors deny at the policy layer.\n\n**`feature.get_public` extended** with two new return fields:\n`viewerHasVoted` + `viewerIsFollowing` (null for anonymous, true /\nfalse for authenticated). Powers the sidebar's filled-state.\n\n**UI updates** on `/help/roadmap/$slug`:\n\n- New vote CTA — filled state when the viewer has voted, outline\n  otherwise. Disabled for shipped / declined features (voting closes\n  on terminals). Toast on success with \"We'll let you know when it\n  ships\" when the auto-follow fired.\n- New follow CTA — separate button beneath the vote, filled state when\n  following.\n- Unauthenticated visitors see a \"Sign in to vote\" CTA that bounces to\n  `/auth/signin?redirect=/help/roadmap/<slug>` and returns to the\n  feature page after sign-in.\n- Signal sidebar (votes / followers / comments) stays and updates\n  optimistically via TanStack Query invalidation on each toggle.\n\n**Tests:** 8 new vitest cases on vote + follow covering policy denial\n(anonymous), not_found, invalid uuid, and event emission shape. 27\nmodule tests pass.\n\nPhase 2c next: the gated \"Request a feature\" submit flow + pgvector\nat-submission dedup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.560Z","updatedAt":"2026-06-13T14:37:40.560Z"},{"id":"2e05a1b8-ada3-4256-8414-9185c0386276","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-clock-policy","type":"changed","scope":"web","summary":"The clock-site (geofence) and allowed-IP forms use the unified Form stack.","body":"Form-polish plan, Phase 5: the clock-site geofence editor (name/lat/lng/radius/address/country)\nand the allowed-IP add form on the HR clock-policy tab now use the unified `useAppForm` + `Form`\nstack with Zod validation and inline errors. Coordinate parsing, the \"use my location\" / \"use as\nentry\" helpers, and payloads are preserved; the auto-save-on-change policy controls, the relink\nmaintenance action, and per-row edit/remove actions are untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:24.365Z","updatedAt":"2026-06-13T15:23:24.365Z"},{"id":"6fa65b1f-c1bb-428a-a97c-43be7640a108","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-client-spaces-c5-search-gate","type":"security","scope":"chat","summary":"Chat search + entity-reference scans honour the client-space confidentiality gate.","body":"`chat.message.search`, `chat.entity.list_references`, and\n`chat.entity.count_references_bulk` now apply the same member-or-gate\nvisibility rule as `chat.space.list` / `chat.space.get` to client-kind spaces.\n\nA channel that lives inside a `kind='client'` space is searchable only if the\nactor:\n\n- is a member of the space, OR\n- holds `chat:client_space:read` (root-only by default; admins by blueprint)\n\n`chat:admin` is **not** a substitute (D6 — client confidentiality is a separate\naxis from chat moderation). The gate is plumbed into the existing ACL CTE via\n`LEFT JOIN chat_spaces` + `LEFT JOIN chat_space_members` so the SQL plan stays\none shape regardless of the actor's permissions.\n\nThe \"💬 N\" reference-count badges that show on CRM / projects / sales detail\npages now hide messages that originated in a customer room the viewer cannot\nsee, instead of leaking the count.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.586Z","updatedAt":"2026-06-13T14:37:40.586Z"},{"id":"b4763f28-b65c-4f02-bde4-dc1c1aca11e5","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ticket-links-schema","type":"added","scope":"support","summary":"Added the schema for linking support tickets to client-owned entities.","body":"Foundation for org-level support context: a new `support_ticket_links`\ntable lets a ticket reference the concrete thing a client owns or bought\n— a project, a tenant-sold subscription, an invoice, a product, or a\nclient engagement. It follows the canonical cross-module link shape\n(`client_engagement_links`, `projects_task_links`), with the target\nvocabulary validated at the action layer so new target types need no\nmigration. The link actions land next. See\ndocs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.633Z","updatedAt":"2026-06-13T14:37:40.633Z"},{"id":"127dd4ab-7a4f-48cd-9201-3995b0a11c14","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-board-optimistic-drag","type":"changed","scope":"support","summary":"Dragging a ticket on the inbox board now moves the card instantly, before the save lands.","body":"The inbox Board view applies drag moves optimistically: the card jumps to\nthe target column the moment you drop it, instead of waiting for the\nserver round-trip and refetch (which caused a visible flash). The\noptimistic placement reconciles against the next refetch, and reverts the\ncard to its original column with an error toast if the save fails. Cards\nin status and assignee columns also show a small priority dot so urgency\nstays visible when the column no longer encodes it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.680Z","updatedAt":"2026-06-15T15:59:11.680Z"},{"id":"008aff7e-b855-4eac-b06b-317673116d6b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ticket-link-actions","type":"added","scope":"support","summary":"Support tickets can now be linked to a client's projects, subscriptions, invoices, products, or engagements.","body":"Added `support.ticket.link`, `support.ticket.unlink`, and\n`support.ticket.links` so an agent can associate a ticket with the\nconcrete thing a client owns or bought — a project, a tenant-sold\nsubscription, an invoice, a product, or a client engagement. The\n`(module, entity)` target set is a closed, validated registry; linking is\nidempotent and org-scoped, gated by the existing `support:ticket:tag`\npermission. This is the org-tier groundwork for showing, per client item,\nthe support attached to it. See\ndocs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T14:37:40.624Z","updatedAt":"2026-06-13T14:37:40.624Z"},{"id":"460ae1b7-d904-4214-ba63-fe536467ffb2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-create-requester-gate","type":"fixed","scope":"support","summary":"New ticket form's Create button now matches the server's requester rule.","body":"The New ticket drawer's submit button enabled whenever a subject plus\n*either* a requester name or email was present, but the server requires a\nclient company OR *both* a name and a valid email — so name-only,\nemail-only, or malformed-email submissions enabled the button then bounced\nwith a footer error. The gate now mirrors the server (company, or name +\nsyntactically-valid email), and the requester hint reads \"name and email\".","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.878Z","updatedAt":"2026-06-15T15:59:11.878Z"},{"id":"28e65925-e5a9-42da-b4e4-1d92cef96944","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-portal-hours-budget","type":"changed","scope":"support","summary":"Client portal warns as retainer hours run low or over budget, with accessible meter.","body":"On the customer support portal, each engagement's hours-used meter now\nturns amber at 80%+ consumed and red once the allotted hours are\nexceeded (previously it silently capped a neutral bar at 100%), and an\n\"over the allotted hours\" note appears when a client goes over budget.\nThe meter is now an accessible `role=\"progressbar\"` with value/label, and\nthe Open / Resolved tabs expose `aria-pressed` state plus focus-visible\nrings for keyboard users.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:12.114Z","updatedAt":"2026-06-15T15:59:12.114Z"},{"id":"9174dcdf-648e-49cb-8411-ee918aa11f1f","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-anti-spam","type":"added","scope":"forms","summary":"Public forms gain a honeypot bot trap and optional email-quality gating (block disposable / free-provider addresses).","body":"Two lead-quality defences for public forms:\n\n- **Honeypot** — public/embedded forms render a hidden trap field. A submission\n  that fills it is quarantined server-side: it returns an ordinary success\n  response but is never persisted or dispatched, so bots aren't tipped off.\n- **Email quality** — the email field can block disposable/throwaway domains\n  (mailinator and friends) and, for B2B capture, consumer webmail (gmail,\n  yahoo, …), keeping junk leads out of the CRM. Toggle per field in the builder.\n\nBoth ship as pure, tested helpers (`@helios/forms/spam`); the honeypot key is\nstripped from persisted answers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:55.446Z","updatedAt":"2026-06-15T19:59:55.446Z"},{"id":"d4dfd699-df17-424d-989c-4198ea021448","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-hr-clients-batch","type":"changed","scope":"web","summary":"Client, HR-settings, employee-detail, and time-tracking forms use the unified Form stack.","body":"Form-polish plan, Phase 5: the client create/edit sheet + contact/lifecycle/activity forms,\nthe HR settings editors (office, department, position, shift, leave policy, holiday), and the\nemployee-detail and time-tracking create/edit forms now use the unified `useAppForm` + `Form`\nstack with Zod validation and inline errors. Edit-mode diff-patch saves, the contact\nportal-invite reveal sequence, ISO-2 country normalisation, same-as-billing shipping\nsuppression, and all create-vs-update payloads are preserved; filters, row actions, and\nfile-upload controls are untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.165Z","updatedAt":"2026-06-13T15:23:25.165Z"},{"id":"32b2078f-fc48-4804-93a5-1d3709085e59","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-detail-sla-block","type":"added","scope":"support","summary":"The ticket detail page shows an SLA block with first-response and resolution deadlines.","body":"The ticket detail sidebar gains an **SLA** block listing the\nfirst-response and resolution targets with their absolute due time and a\nlive status pill: green \"Met on time / late\" once the target is hit,\notherwise \"Due in 3h\" (amber within four hours) or a red \"Overdue 1h\".\n`support.ticket.get` now returns the SLA due timestamps to power it. The\nblock only appears when the ticket has an SLA policy applied, completing\nthe SLA picture across the inbox, board, calendar, and detail page.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.919Z","updatedAt":"2026-06-15T15:59:11.919Z"},{"id":"029b0e0f-86a1-40f7-b019-ed14891a6560","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-builder-settings","type":"added","scope":"forms","summary":"The form builder gains a visual Appearance & completion panel and a searchable field-type picker.","body":"Two builder upgrades:\n\n- **Appearance & completion panel** — brand a form (accent color, font, logo,\n  cover image) and configure the post-submit thank-you screen (title, message,\n  redirect URL + delay) with ordinary inputs, instead of hand-editing JSON.\n- **Searchable field-type picker** — the \"Add field\" menu now has a search box\n  that filters the (38+) field types by name, type, or description, so the right\n  one is a keystroke away rather than a scroll.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:55.937Z","updatedAt":"2026-06-15T19:59:55.937Z"},{"id":"34928bbd-0a95-45de-abce-cae493c6b962","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-linked-items-named","type":"changed","scope":"support","summary":"A ticket's linked items now show the entity's name instead of a raw id.","body":"The \"Linked items\" block on the ticket detail now renders each linked\nentity as a resolved cross-module badge — the project name, subscription\nname, invoice number, product name, or engagement name with its status —\ninstead of a bare UUID. Reuses `<CrossModuleLinkBadge>` (ADR 0013); the\nbadge registry gained the sales subscription/invoice/product and clients\nengagement resolvers. See docs/plans/ORG_SUPPORT_CLIENT_ENTITY_LINKAGE_RESEARCH.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.428Z","updatedAt":"2026-06-13T15:23:25.428Z"},{"id":"45d837fd-34a3-409c-a7df-d38a37f5900a","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbox-sla-chip","type":"added","scope":"support","summary":"Inbox rows and board cards show an SLA urgency chip (due in / overdue).","body":"Open tickets now carry a small SLA chip in the inbox list and on board\ncards, showing the nearest first-response / resolution deadline as a short\nrelative label — \"Due 3h\", \"Due 2d\", or a red \"Overdue 1h\". Deadlines\nwithin four hours render amber. Closed and resolved tickets, and tickets\nwith no SLA target, show no chip, so the signal only appears where there's\na clock still running.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.929Z","updatedAt":"2026-06-15T15:59:11.929Z"},{"id":"e367d344-c631-4056-b9d0-bd763a3affa2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-new-field-types","type":"added","scope":"forms","summary":"Seven new form field types — hidden/UTM capture, consent, country, time, opinion scale/NPS, ranking, and matrix.","body":"The form builder gains seven new field types, available to every form (lead\ncapture, recruitment apply, careers, and more):\n\n- **Hidden** — invisible field captured from the URL, a UTM parameter, or the\n  referrer at load (lead source / campaign attribution).\n- **Consent** — a GDPR/legal checkbox with an optional policy link; mark it\n  required to block submission until ticked.\n- **Country** — a typed ISO-3166 country picker.\n- **Time** — a time-of-day value (HH:mm).\n- **Opinion scale / NPS** — a wide anchored numeric scale (1–5, 1–7) or Net\n  Promoter Score (0–10) with low/high labels.\n- **Ranking** — order a set of options by preference.\n- **Matrix / grid** — a Likert grid: one choice per row across shared columns.\n\nEach ships with builder config + a registry contract test.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:55.943Z","updatedAt":"2026-06-15T19:59:55.943Z"},{"id":"2addddee-b5d3-4887-a36c-a78289659fcc","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-aware-route","type":"added","scope":"chat","summary":"Space-aware /chat/$spaceSlug/$channelId route; legacy /chat/$channelId redirects to it.","body":"The canonical channel URL is now `/chat/<spaceSlug>/<channelId>`. The legacy\n`/chat/<channelId>` route is kept as a permanent redirect: every cold link,\nexternal bookmark, or older internal `<Link>` resolves the channel's owning\nspace slug via the new `chat.channel.resolve_route` action and is rewritten\nto the canonical shape (`replace: true`, the closest TanStack Router\nequivalent of an HTTP 308).\n\nUnscoped channels (legacy DMs with `space_id IS NULL`) fall back to the org's\ndefault space slug, so no channel is left URL-orphaned during the migration.\n\nInternal call sites (sidebar links, command-palette nav, notification deep\nlinks, hover-card \"Message\" CTAs, …) continue to emit the legacy shape and\nget redirected for now; switching them to the space-aware shape directly is a\nfollow-up task and removes the one-shot redirect hop.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.461Z","updatedAt":"2026-06-13T15:23:25.461Z"},{"id":"d6e50e3d-7681-4ed3-9a67-f10d759d81ae","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbox-a11y-tokens","type":"changed","scope":"support","summary":"Support inbox a11y + theming polish — focus rings, design tokens, localized weekdays.","body":"Accessibility and theming polish on the support inbox and entity panel:\nkeyboard focus-visible rings on inbox rows, board cards, and the calendar\nbasis toggle (with `aria-pressed`); the calendar's weekday headers and\nmonth label are now localized via `Intl` instead of hardcoded English\nabbreviations; the agent/unassigned grouping dots use the\n`--accent-info` / `--fg-subtle` design tokens (so they remap in dark mode)\ninstead of hardcoded hex; and the entity \"Support\" panel labels its region\nand list and exposes each ticket's priority dot to screen readers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.937Z","updatedAt":"2026-06-15T15:59:11.937Z"},{"id":"4a44b218-368a-45c7-93b6-0e113d93d9b2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-client-space-multi-client","type":"changed","scope":"chat","summary":"Client spaces can now contain multiple CRM clients (previously one-per-space).","body":"The single-client restriction on client chat spaces is gone. A client space can\nnow contain any number of CRM clients, and a client can appear in any number of\nclient spaces.\n\n**Data model**: a new `chat_space_clients` join table is the canonical record\nof \"which clients are in this space\". The legacy `chat_spaces.client_id`\ncolumn stays as a nullable \"primary\" pointer for back-compat and quick\ndisplay lookups; the partial unique index that enforced one-space-per-client\nwas dropped, as was the CHECK constraint requiring `client_id` to be set when\n`kind='client'`. Existing client spaces are backfilled into the join table\nautomatically.\n\n**New actions**:\n\n- `chat.client_space.add_client(spaceId, clientId)` — idempotent add.\n- `chat.client_space.remove_client(spaceId, clientId)` — refuses removing the\n  last client (archive the space instead); re-points the legacy primary\n  pointer when needed.\n- `chat.client_space.list_clients(spaceId)` — returns name + addedAt +\n  isPrimary for each linked client.\n\n**find_or_create**: still idempotent — returns the most recently created\nclient space that contains the given client. When the client isn't in any\nspace, creates a fresh one and seeds the join row.\n\n**New space modal**: the kind=Client picker is now a multi-select list of\nclients. The first picked client seeds the space; the rest are added via\n`add_client` immediately after creation.\n\nPermission `chat:client_space:manage_members` (already declared in the role\ncatalog) gates add/remove. `chat:client_space:read` gates list. `chat:admin`\nis **not** a substitute for any of these (D6 — client confidentiality is a\nseparate axis from chat moderation).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.476Z","updatedAt":"2026-06-13T15:23:25.476Z"},{"id":"6cae2456-aaf4-4635-95b9-0ec57bd6d6b1","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-panel-sales-detail","type":"added","scope":"support","summary":"Subscription and invoice detail pages now show their linked support tickets.","body":"The `<EntityTicketsPanel>` (linked support tickets, via\n`support.ticket.for_entity`) is now wired into the subscription and\ninvoice detail sidebars, alongside the engagement page. An agent viewing\na subscription or invoice sees the support threads filed against it and\ncan jump straight to a ticket. The panel stays quiet when the viewer\nlacks `support:ticket:read`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.968Z","updatedAt":"2026-06-15T15:59:11.968Z"},{"id":"f07df72f-2383-4ec8-8531-d9f07cb2e7a3","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-theme-completion","type":"added","scope":"forms","summary":"Forms now apply their theme (accent color, font, logo, cover image) and can show a custom thank-you screen with optional redirect.","body":"The form theme — previously stored but ignored by the renderer — now actually\nrenders: the accent color drives buttons + active states (via a CSS variable),\nthe font family applies to the form, and an optional logo + cover image render\nas a brand header. Forms can also define a **completion** screen: after a\nsuccessful submit the respondent sees an in-place thank-you (title + message)\nand is optionally redirected to a URL after a configurable delay. Both are\nopt-in via the form definition, so hosts that handle post-submit themselves are\nunaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:56.220Z","updatedAt":"2026-06-15T19:59:56.220Z"},{"id":"c2c96792-32bb-4729-8ffe-209ddefcbe2d","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-utm-capture-prefill","type":"added","scope":"forms","summary":"Forms auto-capture UTM/referrer attribution and prefill fields from URL parameters; new lead forms capture source + campaign out of the box.","body":"Forms now read the page they load on:\n\n- **Hidden fields** populate from a UTM parameter, any URL query param, the\n  referrer, or a static value — so lead source / campaign attribution is\n  captured automatically.\n- **Any scalar field** is prefilled when a URL query parameter matches its id\n  (e.g. `?email=a@b.com&plan=pro`), with values validated against the field's\n  schema (complex fields are left untouched).\n\nNew CRM lead forms ship with hidden `source` (utm_source) and `campaign`\n(utm_campaign) fields, so leads captured from a campaign link record their\nattribution with no extra setup. Capture runs client-side only and is skipped in\nthe builder preview; a resumed draft still takes precedence.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:56.522Z","updatedAt":"2026-06-15T19:59:56.522Z"},{"id":"52d6c169-5826-4436-99d5-0275cd85cfd8","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ticket-read-scope-idor","type":"security","scope":"support","summary":"Team-scope support agents can no longer read or reply to tickets outside their own queue.","body":"Closed two broken-access-control gaps in support ticket reads. The\nper-row access guard (`actorMayAccessTicket`, used by ticket get / reply /\nmessage list) treated `support:ticket:read:team` as a blanket bypass,\nletting a team-scope agent open or reply to any ticket id directly even\nthough their inbox is narrowed to their own tickets — an IDOR over\nenumerable ticket references. Team scope now falls through to the same\nrequester/assignee/company checks the list applies (org-wide `:read`\nagents are unaffected). Separately, `support.ticket.for_entity` (the staff\n\"Support\" panel on entity pages) is now gated by an agent-tier policy\n(`:read` / `:read:team`); the requester scopes (`:read:own` /\n`:read:my_company`) are denied, so a client cannot enumerate an org's\ntickets by entity id via the action API. Added denial tests for both.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:12.149Z","updatedAt":"2026-06-15T15:59:12.149Z"},{"id":"d53c7f9b-6100-4a10-a2f5-734b85dfb06d","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-type-validators-wired","type":"fixed","scope":"forms","summary":"Email-quality gating and time-format checks now actually run — on the client and (critically) server-side.","body":"The validation engine only ran a field's declarative `validations[]` rules; a\nfield type's own validator was never invoked, so the email-quality gate\n(disposable / free-provider blocking) and the time-of-day format check were\neffectively inert — and not enforced server-side, where the client can't be\ntrusted. Type-level validators now live in a pure, shared map that the engine\nruns for non-empty values on both the client and the server `revalidate` path.\nNo React is pulled into the server bundle.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:56.543Z","updatedAt":"2026-06-15T19:59:56.543Z"},{"id":"424ec59c-aace-4b9c-aa37-1e604d3ff56b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-3a-comments","type":"added","scope":"roadmap","summary":"Added comments + auto-status-change timeline + composer on roadmap feature detail pages.","body":"Phase 3a of the platform Roadmap & Feature Request module — discussion\non feature detail pages.\n\n**Four new actions:**\n\n- `platform.roadmap.comment.post` — session-only. Any authed user posts\n  a comment (≤2000 chars, flat — no nested replies). Updates the\n  denorm `comment_count`. Emits\n  `platform.roadmap.feature.comment_posted` with `kind='user'`.\n- `platform.roadmap.comment.post_official` — gated by\n  `platform:roadmap:manage`. Sets `is_official=true` so the timeline\n  renders with the platform badge.\n- `platform.roadmap.comment.delete` — root-only moderation soft-delete.\n- `platform.roadmap.comment.list_public` — public timeline read. Joins\n  `users.name` for the author label. Caller-controlled sort\n  direction (default: oldest first).\n\n**New event:** `platform.roadmap.feature.comment_posted` — Phase 3b\nnotification + email subscribers will consume it.\n\n**New worker subscriber** (`modules/roadmap/src/jobs/auto-comment-on-status-change.ts`):\nlistens to `platform.roadmap.feature.status_changed` and posts a system\ncomment with `is_status_change=true` and body `\"Status changed: <from>\n→ <to>\"`. Direct DB write — system-internal plumbing inside the same\nmodule that owns the table, not routed through the action layer.\n\nWorker boot registrar wired (`registerRoadmapJobs({ db })` in\n`apps/worker/src/index.ts`).\n\n**Detail page updates** (`/help/roadmap/<slug>`):\n\n- Discussion section with comment count, ordered timeline.\n- Three render modes: official comments render with the\n  `<ShieldCheck>` \"Platform\" badge; status-change comments render\n  inline with a dashed border + RoadHorizon icon; user comments are\n  standard cards.\n- Composer for authenticated users (max 2000 chars, char counter,\n  optimistic toast on success, query invalidation).\n- Unauthenticated visitors see a \"Sign in to add a comment\" hint.\n\n**Tests:** 9 new vitest cases on comment actions — policy denial\n(anonymous + missing manage perm), validation failures (empty body /\ntoo long), `not_found`, event emission shape, list smoke test.\n**45 / 45 module tests pass.** Typechecks clean across\n`@helios/roadmap`, `apps/web`, and `apps/worker`.\n\n`@helios/config` added as a workspace dep on `@helios/roadmap`\n(subscriber logger). The `jobs` subpath export was added so\n`apps/worker` can `import { registerRoadmapJobs } from\n'@helios/roadmap/jobs'`.\n\nPhase 3b next: email subscribers for `feature.status_changed` /\n`feature.shipped` + the four email templates + the\n`/help/roadmap/preferences` cadence page.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.710Z","updatedAt":"2026-06-13T15:23:25.710Z"},{"id":"009a2ebc-631b-4b4e-ac39-c1af8eb5ffb2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-wizard-progress","type":"added","scope":"forms","summary":"Multi-step (wizard) forms now show a progress bar.","body":"Wizard-layout forms render an accent-colored progress bar above the step\nindicator that fills as the respondent advances, with an accessible\n`role=\"progressbar\"` reporting the current step. A small but meaningful\ncompletion-rate nudge for longer multi-step forms.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T19:59:56.759Z","updatedAt":"2026-06-15T19:59:56.759Z"},{"id":"f842ec3a-8e7d-41c9-a550-e886e1689db2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-3b-email-and-preferences","type":"added","scope":"roadmap","summary":"Added per-cadence email notifications on roadmap status changes + the /help/roadmap/preferences page.","body":"Phase 3b of the platform Roadmap & Feature Request module — email\nnotifications + self-service preferences.\n\n**New email subscriber**\n(`modules/roadmap/src/jobs/email-on-status-change.ts`): listens to\n`platform.roadmap.feature.status_changed` and fans out to every\nfollower whose subscription cadence allows the dispatch:\n\n- `immediate` → email every flip (excluding `open` / `under_review`\n  triage-only states)\n- `digest_weekly` → bundled Friday digest (Phase 3c will wire the cron;\n  recipients selecting this today fall through to the digest queue\n  and behave like `shipped_only` until the cron lands)\n- `shipped_only` → email only when the new status is `shipped`\n  (default for any user without a subscription row)\n\nRecipient join: `platform_roadmap_followers` ⋈ `users` ⋈\n`platform_roadmap_subscriptions WHERE unsubscribed_at IS NULL`. Calls\n`email.outbound.send` via the action registry (no package dep on the\nemail module). Idempotency key:\n`platform.roadmap.feature.status_changed.<featureId>.<to>.<recipientEmail>`.\n\n**New email template** at\n`modules/email/src/seeds/templates/roadmap.ts`:\n\n- `platform.roadmap.feature.status_changed` — one template handles all\n  status flips. Mustache conditionals (`{{# isShipped }}`,\n  `{{# isDeclined }}`) render the shipped success callout and the\n  decline-reason callout. Footer carries\n  \"Change email cadence\" + \"Unsubscribe from roadmap emails\" links.\n\nWired into `modules/email/src/seeds/system-templates.ts` (import +\nspread) and added one flow entry to\n`modules/email/src/seeds/flows.ts` (`ownerModule: 'roadmap'`).\n\n**Three new subscription actions** (sessions-only — `roadmapSessionPolicy`):\n\n- `subscription.get_mine` — returns the actor's row (or a synthetic\n  `shipped_only` default when no row exists, with `accountEmail` so\n  the UI can show \"we'd email you at …\").\n- `subscription.update_mine` — upserts the singleton row; idempotent;\n  optional `email` override falls back to the account email when null.\n- `subscription.unsubscribe` — sets `unsubscribed_at = now()`. The\n  email subscriber filters this in its join. Idempotent: no-op when no\n  active row exists.\n\n**New preferences page** at `/help/roadmap/preferences` — sign-in-gated\nform with three radio-style cadence cards (`shipped_only` marked\nDefault, `digest_weekly` marked \"Coming soon\"), the recipient address,\nand a one-click unsubscribe affordance. Re-subscribe is just picking a\ncadence again. The feature detail page now links to it from the\n\"Email preferences →\" hint in the sidebar.\n\n**Tests:** 7 new vitest cases on subscription actions (policy denial\nfor each, validation failures on cadence + email override, the null +\nsynthetic-default return path, idempotent unsubscribe).\n**52 / 52 module tests pass.** Email seeds + flow registry tests\n(`modules/email`) pass — the cross-validator confirms the new flow has\na matching template.\n\n`routeTree.gen.ts` is intentionally NOT in this commit because a\nconcurrent session has an untracked chat route file whose regen was\nfolded into the same auto-generation. Dev-server will pick the new\n`/help/roadmap/preferences` route up on next regen — standard for new\nroutes.\n\nPhase 3c next: digest_weekly cron + in-app notifications.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:23:25.957Z","updatedAt":"2026-06-13T15:23:25.957Z"},{"id":"e1d702f8-621b-45db-8dc3-f2fab195d052","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-4-marketing-apex","type":"added","scope":"marketing","summary":"Added the public marketing /roadmap page with three-column kanban + sign-in-to-vote bounce.","body":"Phase 4 of the platform Roadmap & Feature Request module — the\npublic marketing surface at `{apex}/roadmap`.\n\n**New Astro page** (`apps/marketing/src/pages/roadmap.astro`):\n\n- SSR call to `platform.roadmap.board.summary_public` via the standard\n  marketing → API path (`https://api.{tenantHost}/api/actions/...`).\n  Degrades cleanly when the API is unreachable — page renders a\n  \"coming soon\" placeholder.\n- Three-column kanban (Planned / In progress / Recently shipped) over\n  the operator-curated `roadmap_visible = true` subset. Cards show\n  title, summary, category badge, target label, vote count.\n- Each card links to the in-app `/help/roadmap/<slug>` detail page —\n  unauthenticated visitors are bounced through sign-in by the in-app\n  vote flow, then return to the feature page (no marketing-side auth\n  scaffolding needed).\n- Hero copy + disclaimer pull from `platform_roadmap_settings`\n  (operator-editable). Falls back to opinionated default copy when\n  settings are empty.\n- `publicReadEnabled = false` kill switch renders the \"coming soon\"\n  placeholder so operators can soft-launch.\n- Inline JSON-LD (`schema.org/ItemList`) helps crawlers index every\n  feature, up to 50 items.\n- Two CTAs in the hero: \"Request a feature →\" (deep link to the in-app\n  gated submit form) and \"See what's shipped\" (link to `/changelog`).\n\n**New runtime helper** in `apps/marketing/src/lib/cms-runtime.ts`:\n`getPlatformRoadmapBoard(opts)` + `PlatformRoadmapFeature` /\n`PlatformRoadmapBoard` types mirror the public DTO from\n`modules/roadmap/src/schemas/index.ts`. Five-second SSR timeout with\n`null` fallback (same pattern as `listPlatformChangelog`).\n\n**Nav entries** added in `apps/marketing/src/lib/nav.ts`:\n\"Roadmap\" sits between Changelog and Documentation in the Learn /\nResources mega-menu groups (desktop nav + mobile nav both pick it up\nfrom the shared array).\n\nPhase 5 next: status banner integration on the feature detail page.\nFuture phases (6+) — merge / vote-on-behalf / Linear sync / feeds /\nembeddable widget / tier weighting — remain queued.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:44:36.554Z","updatedAt":"2026-06-13T15:44:36.554Z"},{"id":"c42b237f-3466-4bb2-bdc7-e46160aaea32","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-channel-cross-space-move","type":"added","scope":"chat","summary":"chat.channel.update can now move a channel between spaces (auto-joins members to target).","body":"`chat.channel.update` gains an optional `targetSpaceId` input. When supplied,\nthe action transactionally:\n\n- Reassigns `chat_channels.space_id` to the target.\n- Clears `category_id` (categories are space-scoped — the old one no longer\n  applies in the new space).\n- Auto-joins every existing channel member to the target space (idempotent)\n  so nobody loses visibility through the move.\n- Emits a new `chat.channel.moved` event (with `fromSpaceId` + `toSpaceId`),\n  then the existing `chat.channel.updated` event so realtime subscribers\n  refresh their sidebars / categorisation.\n\nRefused for `dm`, `group_dm`, and `external` channels — those aren't\n\"in\" a space the same way. The actor must also be a member of the target\nspace (so they can't displace a channel into a workspace they don't inhabit);\nmid-flight failure returns `policy_denied` with a clear message.\n\nSchema unchanged. Same Drizzle update path, just one more column in the\npatch. Six new tests cover the move branch.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T15:44:35.854Z","updatedAt":"2026-06-13T15:44:35.854Z"},{"id":"5aab148d-91df-4f10-ad70-3a69f3161f2e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-nav-helper-and-migrations","type":"changed","scope":"chat","summary":"Shared useChatChannelLink helper; hover card / profile pane / inbox / mentions / palette / search / new-DM modal navigate space-aware.","body":"Follow-up to the sidebar space-aware nav (`9fbea8d0`). A new\n`useChatChannelLink()` hook centralises the resolution from\n`channelId → { to, params }` so every chat-side navigation now produces\nthe canonical `/chat/$spaceSlug/$channelId` shape with no redirect hop on\nthe happy path. Falls back to the legacy `/chat/$channelId` route when\neither cache is cold — the redirect via `chat.channel.resolve_route` is\nthe safety net, links never break.\n\nThe hook reads the existing `channelsQueryKey` + `SPACES_QUERY_KEY`\ncaches, so it's a no-op subscription cost on any surface where the\nsidebar / SpaceMenu is already mounted. It also honours hybrid DM pins\n(the URL reflects the user's filed location) and gracefully ignores\ndead pins (DM pinned to a space the user has since left).\n\nMigrated call sites:\n\n- `apps/web/src/components/chat/user-hover-card.tsx` — \"Send DM\" jump.\n- `apps/web/src/components/chat/user-profile-pane.tsx` — \"Message\" CTA.\n- `apps/web/src/components/chat/chat-command-palette.tsx` — channel\n  pick (including the split-mode branch).\n- `apps/web/src/components/chat/search-modal.tsx` — search result open.\n- `apps/web/src/components/chat/new-dm-modal.tsx` — fresh DM open.\n- `apps/web/src/routes/chat/index.tsx` — catch-up inbox channel +\n  mention rows.\n- `apps/web/src/routes/chat/mentions.tsx` — per-mention Link.\n\n260 chat tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:13.446Z","updatedAt":"2026-06-15T17:00:13.446Z"},{"id":"38cced35-20a1-4729-b63a-c0fe6d7e2db3","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-sla-due-denormalize","type":"fixed","scope":"support","summary":"SLA due dates now actually populate the inbox, calendar, dashboard, reports, and emails.","body":"The SLA recompute wrote due-ats only to the `support_sla_events` table,\nnever to the denormalized `firstResponseDueAt` / `nextResponseDueAt` /\n`resolutionDueAt` columns on the ticket — yet the inbox list, the SLA-due\ncalendar, the ticket detail SLA block, the dashboard \"approaching SLA\"\ncounts, the SLA breach reports, and the SLA emails all read those columns.\nThe result: every one of those surfaces silently showed no SLA data. The\nrecompute now denormalizes the computed due-ats onto the ticket row in the\nsame pass (events table stays the source of truth for met/breach history),\nso all six SLA read-surfaces light up. The write is a raw update, not an\naction, so it emits no event and can't re-enter trigger fan-out.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:14.535Z","updatedAt":"2026-06-15T17:00:14.535Z"},{"id":"2526d9af-75b5-474a-863c-127f26c7633b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbound-sender-verify","type":"security","scope":"support","summary":"Email-to-ticket no longer lets a guessed ticket reference inject a public reply.","body":"Inbound email threaded onto a ticket two ways: by `In-Reply-To` (matching\nan unguessable outbound Message-ID — trustworthy) or by a `[TKT-NNNN]`\nsubject reference. Ticket references are sequential and enumerable, yet\nthe subject path posted the email as a **public reply with no sender\ncheck** — so anyone able to email an org's support inbox could append\ncontent to another customer's ticket (and trigger that customer's reply\nnotification), a ticket-injection / phishing vector.\n\nSubject-referenced inbound mail is now only posted as a public reply when\nthe sender matches the ticket's requester (stored requester email, or the\nlinked requester user's account email). Otherwise it lands as an internal\nnote flagged `senderVerified: false` — agents still see it, but it can't\nmasquerade as a requester reply. The In-Reply-To path is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:14.237Z","updatedAt":"2026-06-15T17:00:14.237Z"},{"id":"3dde2c23-1708-4447-8148-417732068612","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-business-minutes-clamp","type":"fixed","scope":"support","summary":"Business-minutes counter no longer under-counts when a span ends before a day opens.","body":"`businessMinutesBetween` (the SLA engine's elapsed-business-time helper)\nunder-counted when the end instant fell before a working day's window\nopened: the final day contributed a negative amount that subtracted from\nminutes already counted on prior days (e.g. Mon 10:00 → Wed 07:00 with\n9–5 hours returned 780 instead of 900). The per-day contribution is now\nclamped at zero. Added a regression test.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:14.260Z","updatedAt":"2026-06-15T17:00:14.260Z"},{"id":"d217413f-d120-4cd9-8f66-40a956268903","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-kb-search-draft-leak","type":"security","scope":"support","summary":"KB search no longer returns unpublished articles to non-staff via a client flag.","body":"`support.kb.article.search` only filtered to `status = 'published'` when\nthe caller passed `publicOnly: true`, and its policy admits anonymous\ncallers — so a public visitor could omit the flag and receive\npublic-visibility **draft / in-review** articles (the per-article\n`support.kb.article.get` already gated this correctly; search did not).\n\nThe published-only constraint is now derived from the actor's permissions,\nnot the client flag: callers without `support:kb:article:read_internal`\nalways get published articles only, matching the single-article path.\nInternal staff can still search unpublished content; the admin article\nlist (a separate action) is unaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:14.559Z","updatedAt":"2026-06-15T17:00:14.559Z"},{"id":"05f8b87c-f357-4c1a-92be-c56cab39b250","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-webhook-ssrf","type":"security","scope":"support","summary":"Support webhook URLs can no longer target the platform's internal network (SSRF).","body":"Support webhook endpoint URLs were only checked for an `https://` prefix\n(and `http://localhost` was explicitly allowed). A tenant admin could\ntherefore point a webhook at the platform's own services —\n`http://localhost:5432`, `https://10.x`/`192.168.x` internal hosts, or\n`https://169.254.169.254/` cloud metadata — and the delivery drainer would\nPOST to it and store the response on the delivery row (visible in the admin\nUI), an SSRF / internal-data exfiltration path.\n\nWebhook URLs are now validated against loopback, private (10/8, 172.16/12,\n192.168/16), link-local + metadata (169.254/16), and IPv6\nloopback/ULA/link-local ranges, plus `*.local` / `*.internal` hostnames.\n`http://localhost` is permitted only outside production for local dev.\nAdded a focused test suite for the guard.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:00:14.880Z","updatedAt":"2026-06-15T17:00:14.880Z"},{"id":"62cda88c-7140-428e-8c0e-b986b3a600e7","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-ephemeral-messages","type":"added","scope":"chat","summary":"Slack-style ephemeral chat messages (\"only you can see this\") with a sweep cron action.","body":"A new `chat_ephemeral_messages` table backs short-lived, recipient-scoped\nchat surfaces — bot guidance, AI compose-side suggestions, per-user reminders\nanchored to a channel. They are deliberately stripped-down: no threads,\nno reactions, no edits, no soft-delete history.\n\n**Actions**:\n\n- `chat.message.post_ephemeral(channelId, targetUserIds, bodyPlain, ttlSeconds?, embeds?)` —\n  posts the row. Validates that every target is a current channel member\n  (so an ephemeral can't be a confidentiality leak vector). Default TTL is\n  10 minutes (max 24 h). Emits `chat.ephemeral.posted` carrying the full\n  `targetUserIds` array so the realtime layer can broadcast to each\n  target's user-topic only.\n- `chat.message.list_ephemeral(channelId)` — returns ephemerals targeting\n  the actor in that channel that haven't expired. Read-only.\n- `chat.ephemeral.sweep()` — admin / system-only maintenance op that\n  deletes every row whose `expires_at` is in the past. Idempotent;\n  designed to run hourly via the worker cron.\n\n**Permissions**: `chat:message:post` posts, `chat:channel:read` lists,\n`chat:admin` (or a system context) sweeps. `chat:admin` substitutes for\npost + list.\n\n**Indexes**: `(channel_id, expires_at)` btree for the list path, GIN on\n`target_user_ids` for the `@>` membership test, btree on `expires_at` for\nthe sweep.\n\nBackend only — composer / channel-view surfacing the new ephemeral panel\nis a follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:38.863Z","updatedAt":"2026-06-13T16:40:38.863Z"},{"id":"1dffbee9-25f4-4ac3-a4ce-074c0648eff2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-redirect-and-tab-fix","type":"fixed","scope":"web","summary":"Added /roadmap → /help/roadmap redirect and fixed double-highlight on the Help tab.","body":"Two small fixes after Phase 4 testing surfaced UX issues:\n\n- **/roadmap redirect.** Customers landing on the app domain and typing\n  `/roadmap` directly (matching the marketing apex URL shape) were\n  hitting a global 404 instead of the in-app roadmap. New file\n  `apps/web/src/routes/roadmap.tsx` is a thin `beforeLoad` stub that\n  throws a `replace: true` redirect to `/help/roadmap`. Back button\n  stays clean (no two-URL bounce).\n- **Help tab double-highlight.** The Help tab's match function on\n  `/help/*` excluded changelog, status, and docs but missed the new\n  `/help/roadmap` route added in Phase 2a. Visiting the roadmap caused\n  BOTH the Help tab and the Roadmap tab to render as active. Fixed\n  by adding the `!p.startsWith('/help/roadmap')` exclusion.\n\nNote: an unrelated Vite HMR \"Failed to fetch dynamically imported\nmodule\" symptom on `/help/*` after pulling Phases 3b + 4 needs a dev-\nserver restart — `routeTree.gen.ts` regenerates as new route files\nland and stale chunk hashes from the prior session don't survive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:40.181Z","updatedAt":"2026-06-13T16:40:40.181Z"},{"id":"10a58303-920f-46b4-8eb3-aa5c0f11e028","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-switcher-actually-switches","type":"fixed","scope":"chat","summary":"SpaceMenu picks now actually navigate to the picked space (was a no-op TODO).","body":"The chat sidebar's space switcher was rendering but `onSelect` was a no-op TODO\nwaiting on the canonical `/chat/$spaceSlug/$channelId` route to land. Now that\nthe route exists, the handler does its job:\n\n- Persists the pick to `localStorage` (cross-tab) and to\n  `chat.space.update_last_visited` (cross-device).\n- Finds the first joined channel in the picked space (a public / private /\n  announcement channel — DMs are space-agnostic) and navigates to\n  `/chat/<spaceSlug>/<channelId>`.\n- Falls back to `/chat` (catch-up inbox) when the actor has no channel in the\n  new space yet.\n\nThe trigger label also reflects reality now: `activeSpaceId` is derived from\nthe current channel's `spaceId` first (visible through `chat.channel.list`,\nwhich gains the field), then from the persisted pick, then from null —\nmatching what the user actually sees inside the rail.\n\nSchema: `ChannelRow` (the `chat.channel.list` output row) gains a nullable\n`spaceId` field. Null for DMs / group DMs / external / pre-Phase-1.A unscoped\nchannels. Sidebars and switchers branch on it; legacy clients that ignore the\nfield keep working.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:39.563Z","updatedAt":"2026-06-13T16:40:39.563Z"},{"id":"14d78506-4023-4804-a0de-7d9a962281fc","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-phase-5-status-banner","type":"added","scope":"roadmap","summary":"Added status-incident banner on roadmap feature pages + the related-roadmap-feature-id field on the status incident form.","body":"Phase 5 of the platform Roadmap & Feature Request module — slim\nintegration with the status module. Surfaces an \"experiencing issues\"\nbanner on a roadmap feature page whenever an open status incident\nreferences it.\n\n**Per spec §14: no FK between the two tables.** The link is a free-text\nUUID stored in `platform_status_incidents.meta.related_roadmap_feature_id`.\nThis keeps the two modules architecturally independent — roadmap and\nstatus are different concerns, and the only thing crossing the boundary\nis a one-query banner read.\n\n**Roadmap side**\n(`platform.roadmap.feature.get_public`):\n\n- New `relatedIncidents` field on the response — at most 3 open\n  incidents whose `meta.related_roadmap_feature_id` equals the feature's\n  id. Filters resolved + deleted + non-public incidents (so banners\n  only fire for things customers should actually see).\n- One additional indexed query per feature page load — uses the\n  existing `meta` jsonb without a schema change.\n\n**Status side**\n(`platform.status.incident.create` / `update`):\n\n- New optional `relatedRoadmapFeatureId` field on both inputs (UUID,\n  free-text, nullable). On `create`, sets\n  `meta.related_roadmap_feature_id`. On `update`, merges into the\n  existing `meta` blob — pass `null` to clear, omit to leave untouched.\n\n**UI:**\n\n- Roadmap feature detail page (`/help/roadmap/<slug>`) renders a red\n  callout for each related incident at the top of the page — title,\n  impact label, lifecycle status, and a \"See status →\" arrow that\n  deep-links to `/help/status#<incident-slug>`. Banner disappears\n  on the next page load once the operator resolves the incident.\n- Status admin (`/saas/platform/status` → New incident form) gains a\n  \"Related roadmap feature\" UUID input. Helper copy explains what it\n  does so the operator knows the banner will surface.\n\nTests: existing 52 / 52 module tests still pass after the\n`get_public` extension. Defensive filter in `loadRelatedIncidents`\nguards against malformed rows.\n\n**Explicit non-goals** (recorded so future maintainers don't relitigate):\n\n- No FK between `platform_status_incidents` and\n  `platform_roadmap_features`. Free-text UUID by design.\n- No event flow from status → roadmap. The banner is a query, not a\n  subscription.\n- No flow in the reverse direction either. Roadmap never writes to\n  status (per spec §14).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:39.858Z","updatedAt":"2026-06-13T16:40:39.858Z"},{"id":"d2062b65-641b-4046-822e-53dbc3370326","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-new-ticket-sheet","type":"changed","scope":"support","summary":"The \"New ticket\" form is now a polished drawer with a client picker for filing on a client's behalf.","body":"Creating a ticket now opens a polished right-drawer sheet (matching the\nproject-creation surface) with section cards and searchable pickers. Agents\ncan file a ticket **on behalf of a client** via a searchable client picker\n(sets the requester company), or for a guest by name + email — alongside\nthe subject, description, priority, type, group, and tags. Submit with\n⌘/Ctrl+Enter.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:40.181Z","updatedAt":"2026-06-13T16:40:40.181Z"},{"id":"074feda8-4eb5-4cbc-9513-15692d4b499c","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbox-assignee-axis","type":"added","scope":"support","summary":"Inbox Grouped + Board views can now group by assignee, with agent names.","body":"The group-by toggle gains an **Assignee** axis alongside status and\npriority. Grouped and Board views now bucket tickets by their assigned\nagent (resolved to names via the org member list) plus an Unassigned\nbucket. On the board, dragging a card to an agent's column reassigns the\nticket (`support.ticket.assign`), and dropping on Unassigned clears the\nassignee.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.549Z","updatedAt":"2026-06-13T19:04:25.549Z"},{"id":"158d0a97-bd15-4328-bff3-3befd926edb2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-widget-color-injection","type":"security","scope":"support","summary":"Widget theme color is validated to prevent CSS injection into the loader.","body":"The widget loader interpolates `theme.primaryColor` straight into a\n`<style>` block, but the field accepted any string up to 20 chars — so a\nvalue like `red}.panel{display:none}` could break out of the rule and\ninject arbitrary CSS into the widget. `primaryColor` is now restricted to\nthe character set that appears in real color values (hex, `rgb()`/`hsl()`,\nnamed colors), blocking the CSS-breakout characters (`;{}:<>`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:28.292Z","updatedAt":"2026-06-15T17:29:28.292Z"},{"id":"ee3cb14c-1d12-4a30-bd9b-cb90111a747f","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbox-board-view","type":"added","scope":"support","summary":"The support inbox gains a Kanban board view — drag a ticket between columns to change its status or priority.","body":"The agent inbox now has a **Board** (Kanban) view: tickets are laid out in\ncolumns by status (or priority, via the group-by toggle), and dragging a\ncard to another column changes its status (`support.ticket.change_status`)\nor priority. Cards show the reference, subject, requester, and a priority\ndot, and click through to the ticket. Calendar view follows next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.551Z","updatedAt":"2026-06-13T19:04:25.551Z"},{"id":"5bdf714e-98d4-4e30-84e6-60bea3cc91b3","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-ai-bot-scope-cap","type":"security","scope":"support","summary":"AI bot now org-scopes tokenless conversation posts and caps turns per chat.","body":"Two hardening fixes on the support AI bot:\n\n- `support.ai.conversation.post` loaded the conversation by id and only\n  checked authorization when the conversation had a visitor token — a\n  *tokenless* conversation (internal/portal) had no check at all, so a\n  caller who learned its id could post to another tenant's conversation\n  and drive billed replies. It now falls back to an org match (mirroring\n  `support.ai.conversation.messages`).\n- Added a hard per-conversation turn cap (40 visitor messages): past it the\n  bot hands off to a human instead of replying, so a public visitor can't\n  hold one chat open and drive unbounded model calls regardless of budget.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:28.293Z","updatedAt":"2026-06-15T17:29:28.293Z"},{"id":"df08045c-9a92-4822-a126-56de301f7fec","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-sidebar-active-space-filter","type":"changed","scope":"chat","summary":"Chat sidebar now filters channels to the active space (Slack-style); DMs always visible.","body":"The chat sidebar pivots its channel list around the active space picked from\nthe SpaceMenu. A channel renders when:\n\n- it lives in the active space, OR\n- it's a `dm` / `group_dm` / `external` conversation (those are\n  space-agnostic by design — your DMs follow you across spaces, same as\n  Slack), OR\n- it's an unscoped legacy row with `space_id IS NULL` (so pre-Phase-1.A\n  data isn't orphaned during the migration window), OR\n- `activeSpaceId` is null (no pick yet — back-compat default).\n\n\"Mark all as read\" is scoped to the visible channels, matching Slack\nsemantics: clearing space A's unreads doesn't touch space B. The\ncross-space rail badge (`useChatUnreadTotal`) stays unfiltered so the\ntopbar count still surfaces unreads from every space at a glance.\n\nThe SpaceMenu pivot from the previous commit (`df696822`) already drove\n`activeSpaceId` from the current channel's `spaceId` + a localStorage\nfallback; this commit completes the Slack-like loop by making the rest\nof the sidebar honour it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:24.170Z","updatedAt":"2026-06-13T19:04:24.170Z"},{"id":"115f10f6-b591-4c06-a062-0bb80536f7e0","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-ephemeral-panel","type":"added","scope":"chat","summary":"Channel view now surfaces \"only visible to you\" ephemeral messages as a stacked banner with auto-expire.","body":"The chat backend has shipped `chat.message.post_ephemeral` /\n`list_ephemeral` for a while; this commit adds the first frontend surface.\nWhenever a channel has active ephemerals targeted at the viewer\n(AI guidance, bot reminders, integration notes), they now render as a\nstacked banner above the message list:\n\n- Each row shows an \"Only visible to you\" pill, the body text, and a live\n  countdown until expiry (Xs / Xm / Xh).\n- Rows fade visually as they approach expiry — soft hint that they're\n  impermanent.\n- Hovering reveals a small ✕ to dismiss a single row (per-session UI\n  state; the server still owns the expire schedule).\n- The panel renders nothing when there are zero active rows — no empty\n  wrapper, no layout shift.\n\nPolls every 30 s (cheap server-side; the channel-view already paints\neach second for typing). A one-second internal tick drives the countdown\nwithout a useQuery refetch. Cache key includes channelId so a channel\nswitch fetches fresh data and tears down the prior subscription. All\nstrings are i18n-keyed; the wrapper carries `role=\"region\"` with an\naria-label and the dismiss button + remaining-time chip have\n`aria-label`s wired through `tt()`.\n\nThe compose surface for the actor to author an ephemeral (toolbar\nbutton + recipient/body/TTL modal) is the planned follow-up. This panel\nis useful immediately because Helios's AI / integration paths already\nemit ephemerals.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:27.581Z","updatedAt":"2026-06-15T17:29:27.581Z"},{"id":"b831dc68-d3e6-44d5-88b8-a5e28df4a354","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-dm-mention-hardening","type":"security","scope":"chat","summary":"DM @mention picker is scoped to the conversation members; broadcast and outsider mentions in DMs are refused.","body":"DMs and group DMs are now hardened against mention-based leakage. Two\nchanges shipped together:\n\n**Picker scope (`chat.user.search`).** When the action is called with a\n`channelId` whose type is `dm`, `group_dm`, or `external`, the candidate\npool is restricted to that channel's actual members. The DM @-picker now\nsurfaces only the counterpart (and the actor's self, which the composer\nalready excludes). External channels surface only their resolved members.\n\nPublic / private / announcement channels in team spaces still see the\nfull org pool. Client-space channels keep the existing C6 space-member\nfilter.\n\n**Mention validation (`chat.message.post`).** For DMs and group DMs the\naction refuses:\n\n- `@channel` and `@here` — broadcast mentions inside a small private\n  room are pointless and noisy; the recipient is the conversation.\n- `@user` pointing at a non-member — would emit a notification to\n  someone OUTSIDE the conversation with a link they can't follow. The\n  picker filter normally prevents this, but the server-side check\n  catches manually crafted payloads and stale mention chips pasted\n  from another channel.\n\nBoth refusals return `validation_failed` with a structured detail\n(`{ kinds: [...] }` or `{ outsiders: [...] }`) so the composer can\nsurface a precise inline error.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:24.192Z","updatedAt":"2026-06-13T19:04:24.192Z"},{"id":"c27d0166-ad80-407e-81af-5b346682ef9e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-widget-origin-enforce","type":"security","scope":"support","summary":"Embeddable widget allow-list is now enforced against the real Origin header.","body":"Public support-widget actions validate the embedding site against the\nwidget's `allowedOrigins`, but the handler read `origin` from the request\n**body** — which any caller can set to an allow-listed value, making the\nlist decorative (config could be read and tickets/conversations filed into\nany org's widget from any site). The API edge now overwrites `origin` with\nthe real browser `Origin` header for every widget-public action before the\nhandler runs; a browser cannot forge its own Origin, and a request with no\nOrigin fails a non-empty allow-list. Widgets with no configured origins\nremain open by their existing (documented) default.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T17:29:28.511Z","updatedAt":"2026-06-15T17:29:28.511Z"},{"id":"98c3f78b-926b-4db1-a7f4-645c79fe3fa6","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-webhook-submission","type":"added","scope":"forms","summary":"Forms can now POST submissions to an outbound webhook (SSRF-guarded, best-effort).","body":"A form's submission target can now be a `webhook`: set\n`submission: { kind: 'webhook', webhookUrl: 'https://…' }` and each submission is\nPOSTed as JSON (`{ orgId, definitionId, submissionId, answers }`, with\n`answerToInputMap` applied) to that URL. Delivery is SSRF-guarded (https only;\nprivate / loopback / link-local / cloud-metadata hosts rejected), time-boxed, and\nfire-and-forget so it never adds latency to or fails the submit — the submission\nrow is always committed first. (The URL is operator-configured; the guard is a\nliteral-host check, with resolving DNS-rebinding protection a later hardening.)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:12:12.617Z","updatedAt":"2026-06-15T20:12:12.617Z"},{"id":"09edf711-9f81-4362-8baa-cd5eda918e5a","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-builder-dnd-duplicate","type":"added","scope":"forms","summary":"The form builder gains drag-and-drop reordering and duplicate for fields and sections.","body":"Building forms is faster: drag a field within its section (or a whole section)\nby its grip handle to reorder, and duplicate any field or section in one click.\nDuplicates get fresh unique ids so nothing collides. The existing up/down\nbuttons remain as a precise, keyboard-friendly fallback.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T20:31:31.445Z","updatedAt":"2026-06-15T20:31:31.445Z"},{"id":"1138613f-f219-47ff-b952-16b0275cce2c","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-switch-respects-pick","type":"fixed","scope":"chat","summary":"Picking a space in the switcher now actually pivots the sidebar (was flicker-reverting to the current channel's space).","body":"The space switcher used to flash the new space's name on the trigger but the\nsidebar kept showing the previous space's channels. Root cause: the derived\n`activeSpaceId` let the **current channel's** spaceId win over the user's\nexplicit pick. Picking Space B while viewing a default-space channel went:\n\n1. setState → persisted = Space B,\n2. navigate() fires (async),\n3. next render still has the old URL → activeChannelSpaceId = Default,\n4. activeSpaceId = activeChannelSpaceId ?? persisted = Default,\n\n…and the sidebar reverted to Default before the navigate even landed.\n\nFix: the explicit pick (from the switcher) is now the authoritative source.\nAn effect auto-syncs it when the URL settles into a channel from a different\nspace (so search-result deep links, entity-chip jumps, and the back button\nstill feel right), but a small in-flight guard skips that sync until the\nnavigate after a pick has actually changed the URL. Result:\n\n- Pick a space → sidebar pivots immediately, even when the picked space has\n  no channels (the user lands on `/chat` and the sidebar still shows the\n  picked space's view).\n- Deep-link to a channel in a different space → sidebar follows.\n- Refresh → localStorage rehydrates the pick → sidebar holds steady.\n\nDefensive: the `inActiveSpace` filter treats both `null` AND `undefined`\nspaceId values as \"legacy unscoped\" (show always). A stale client cache that\npredates the `spaceId` field landing in `chat.channel.list` output now\ngracefully shows everything until the next refetch instead of hiding the\nentire sidebar.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:24.195Z","updatedAt":"2026-06-13T19:04:24.195Z"},{"id":"13b63d05-439b-41fa-bbf0-45dabfa092ef","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"plans-rework-phase-e3-upgrade-preview","type":"added","scope":"web","summary":"Selecting a non-current plan on `/settings/billing` now opens an upgrade-preview dialog with a side-by-side feature diff — \"What you'll unlock\" and \"What will change\" — before the subscription change lands.","body":"Phase L.5 Phase E3 of the plans rework. Closes spec §5.3.\n\n**Behaviour change.**\n\nPreviously: clicking a non-current plan on `/settings/billing`\nfired `saas.subscription.set` immediately. No confirmation, no\ndiff.\n\nNow: opens `UpgradePreviewDialog`. Operators see:\n\n- Title bar with the price delta — e.g. `Starter` → `Business` ·\n  `$19/mo` → `$79/mo`.\n- **What you'll unlock** section — every public catalog entry\n  the target plan enables that the current one doesn't.\n  Bordered card, accent `+` glyph, marketing copy from the L.5\n  Phase A1 catalog metadata.\n- **What will change** section — features the target plan\n  removes (downgrades). Warning-tone background, `!` glyph.\n- Empty-state message when only billing-cycle / price changes\n  apply — no false alarms when the two plans are feature-\n  identical.\n- Cancel + Switch plan buttons in the footer; clicking Switch\n  fires the original `saas.subscription.set`.\n\nCurrent-plan picker clicks still skip the dialog (no diff\nagainst itself). Trial flow unchanged — trials don't go through\nthe preview (they auto-expire, no permanent commitment to show\na diff for).\n\n**Diff computation.**\n\nFor each `publicCatalogEntries()` entry:\n- Boolean: `from === true` vs `to === true`, classified as\n  gained (false → true) or lost (true → false).\n- Number: typeof check; classified as gained when going from\n  null/zero → number OR larger number, lost otherwise.\n- Skips entries where both plans have the same resolved value.\n- module_array entries skipped (handled by the existing\n  modules-list card).\n\n**Deferred.**\n\nPro-rated mid-cycle pricing calculation. The existing\n`computePlanDowngradeImpacts` helper covers some of this for\nthe seat-cap case; full proration math integrates with the\nsubscription action's billing-cycle handling and lands in a\nfollow-up.\n\nSpec: docs/plans/PLANS_REWORK_SPEC.md §5.3.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:24.860Z","updatedAt":"2026-06-13T19:04:24.860Z"},{"id":"680c1abf-cddd-420e-8a13-a5170cbfc6e5","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"plans-rework-phase-d2-marketing-freshness","type":"added","scope":"infra","summary":"`pnpm fetch-plans` now fails the build with exit code 2 when STRICT_PRICING_FRESHNESS=true AND the live API call falls back to the bundled snapshot. Soft drift-warning when plan counts diverge.","body":"Phase L.5 Phase D2 of the plans rework. Closes spec §4.4\n(\"Build-time freshness check\").\n\n**Hard guard (CI).**\n\n`apps/marketing/scripts/fetch-plans.ts` now consults\n`STRICT_PRICING_FRESHNESS`. When set to `\"true\"` AND the fetch\nfalls back to the bundled `FALLBACK_PLANS` (API unreachable,\nnon-2xx response, empty payload), the script exits with code 2\n+ a clear error message:\n\n```\n[fetch-plans] STRICT_PRICING_FRESHNESS=true and the API call\nfell back to the bundled catalog. Build aborted to prevent\nshipping a stale pricing page.\n```\n\nLocal dev keeps the lenient behaviour — a fallback warning\nprints but the build continues — so disconnected work on the\nmarketing site stays productive. Add the env var to CI only.\n\n**Soft drift warning.**\n\nWhen the API succeeds but returns a count different from\n`FALLBACK_PLANS.length`, the script logs a warning:\n\n```\nBundled catalog has 4 plans; live API returned 5. Consider\nupdating the FALLBACK_PLANS snapshot in this file so the build\nworks offline + matches what the API serves.\n```\n\nDoesn't fail the build — the live data is the source of truth\nfor that build — but surfaces in CI logs so operators notice the\nsnapshot has drifted from production. Updating\n`FALLBACK_PLANS` keeps offline builds + emergency rebuilds\nmatching reality.\n\n**Why two levels.** A hard guard alone is too strict (every\nweekend deploy needs a healthy API). A soft warning alone is\ntoo forgiving (operators ignore log lines). The pair lets CI\nopt into strict mode while local + emergency builds stay\nunblocked.\n\nSpec: docs/plans/PLANS_REWORK_SPEC.md §4.4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:24.447Z","updatedAt":"2026-06-13T19:04:24.447Z"},{"id":"5f17bf95-f3eb-4789-bb84-fb21c7d25742","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-auth-and-form-fixes","type":"fixed","scope":"roadmap","summary":"Fixed wrong signin URLs + me-loading flash on roadmap pages, and the broken submit form.","body":"User-reported bugs surfaced while testing the customer roadmap flow.\n\n**Auth bugs:**\n\n- Roadmap pages were linking to `/auth/signin?redirect=...` — the actual\n  route is `/login?redirectTo=...`. Every \"Sign in\" CTA on\n  `/help/roadmap/<slug>`, `/help/roadmap/new`, and\n  `/help/roadmap/preferences` was either 404-ing or bouncing authed\n  users to the dashboard (because the redirect param name was ignored).\n  All three routes now point at the correct `/login?redirectTo=` URL.\n- `/help/roadmap/<slug>` was reading `useMe()` without honoring its\n  `isLoading` state, so an authed visitor saw \"Sign in to vote\" for a\n  beat before the query resolved. Now renders a skeleton placeholder\n  while `me` is in-flight; the CTA only appears once we're sure the\n  viewer is anonymous.\n\n**Form bugs** (`/help/roadmap/new` was crashing at runtime):\n\n- `form.useStore(...)` calls didn't exist on the AppForm shape (Phase 2c\n  bug that slipped past the module typecheck because the consuming app\n  wasn't included).\n- `e.error?.code` / `e.error.details` referenced fields that aren't on\n  `ActionCallError` — the class exposes `code` + `details` directly.\n- `FormField` was being given `label` + `hint` props that aren't in its\n  type — the primitive expects render-prop composition or\n  `FormItem`/`FormLabel` children.\n\nReplaced the broken Form-stack usage with the simpler\n`useState`-driven shape that the rest of the app uses for short forms.\nAdded a local `<Field>` helper to render label + hint copy. Polished\nmulti-form composition is queued for a follow-up.\n\nThe dedup confirmation dialog flow (server returns `duplicate_likely`\n→ form re-renders matches → user picks \"Submit anyway\") now actually\nworks end-to-end.\n\n**Items queued for follow-up** (user-reported, not addressed here):\n\n- Image upload on feature submission + admin authoring.\n- \"Public page shows only 3 [items]\" — kanban code path is uncapped at\n  200 items per column; needs confirmation whether the issue is\n  data-driven (only 3 features promoted to `roadmap_visible = true`)\n  or a UI cap I missed.\n- Form-stack polish: rewrite the `<Field>` helper into proper\n  `<FormItem>` / `<FormLabel>` / `<FormControl>` composition once the\n  basic functional path is solid.\n- Image / file attachments on feature body.\n\nThese are the next priority items before Phase 6 (merge / vote-on-\nbehalf / Linear sync).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.469Z","updatedAt":"2026-06-13T19:04:25.469Z"},{"id":"d77f7e72-6464-4a3c-9b0c-7c4714b1116f","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-form-polish","type":"changed","scope":"roadmap","summary":"Polished both roadmap forms to use the canonical Form stack — proper labels, hints, validation, a11y.","body":"User-requested polish: both the customer submission form\n(`/help/roadmap/new`) and the admin create/edit/settings forms\n(`/saas/roadmap`) now use the canonical `useAppForm` + `Form` +\n`FormInput` / `FormTextarea` / `FormSelect` / `FormSubmit` stack from\n`@helios/ui`.\n\n**What changed:**\n\n- Replaced the temporary `<Field>` helper on `/help/roadmap/new` with\n  the convenience wrappers (`FormInput`, `FormTextarea`, `FormSelect`)\n  that own their label + hint + error chrome. Each field now ships a\n  proper `<FormLabel>` linked via `aria-labelledby` and a\n  `<FormMessage>` slot for validation errors.\n- `useAppForm` is back on the customer form — title + summary read\n  reactively via `useStore(form.store, …)` so the live similarity\n  debouncer stays accurate without forcing every keystroke through a\n  parent `useState`.\n- Attachment uploads stay outside the Form stack (they're a side\n  channel — bytes upload via presigned PUT before the form submits)\n  but the new `AttachmentsField` component matches the FormItem\n  spacing + typography so the visual rhythm is unbroken.\n- Admin authoring (`/saas/roadmap` → New feature sheet), the edit\n  sheet, and the settings tab all collapsed their bare\n  `<FormField name= label= hint= ><FormInput/></FormField>` pattern\n  (where label/hint were silently dropped on bare `FormField`) into\n  `<FormInput name= label= hint= />` directly. Labels now actually\n  render.\n\n**Why:** the canonical wrappers wire up `id` + `htmlFor` + `aria-*`\nlinkage between label, control, hint, and error message in one place.\nHand-rolled label markup loses that linkage and screen readers can't\nfollow the field-to-error association. The wrappers also handle the\nclient-side validation error from the Zod resolver — previously errors\nsilently swallowed.\n\n**Net change:** -70 / +120 LOC across the two files. No behaviour\nchange beyond fixing a11y and rendering the previously-missing labels\non `/saas/roadmap` forms.\n\nTests still pass (52 / 52). Typechecks clean. Attachment upload flow\n(Phase 6 image work) survives the rewrite untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.522Z","updatedAt":"2026-06-13T19:04:25.522Z"},{"id":"7208cad8-9c6d-4d05-aa93-073312d3becc","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"forms-inbound-endpoint","type":"added","scope":"forms","summary":"External websites (WordPress form plugins, APIs) can POST submissions into a Helios form via an inbound endpoint.","body":"A form can now accept submissions from off-platform. Set an `inbound` token on\nthe form and external sites POST to `/api/forms/inbound/<formId>` with the token\nin the `X-Webhook-Token` header (or `?token=`). The endpoint accepts both JSON\nand `application/x-www-form-urlencoded` (so Contact Form 7 add-ons, WPForms,\nGravity, Fluent, and Elementor webhooks all work), maps external field names\nonto the form's fields (direct + common aliases like `your-email` → email) and\nkeeps anything unmapped in a `raw` bucket, then funnels into the same\n`forms.public.submit` path as a first-party submission — so re-validation,\nhoneypot, rate-limiting, persistence, and the submission target (e.g. CRM lead\ncreation) all run once, in one place. Phase A of the website-integration plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["forms","claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T22:13:59.540Z","updatedAt":"2026-06-15T22:13:59.540Z"},{"id":"5edc09fb-3d02-4f9e-9325-4b0d555508e7","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"plans-rework-phase-e4-tenant-picker-matrix","type":"added","scope":"web","summary":"Tenant plan picker on `/settings/billing` gains a Cards | Matrix toggle — the matrix view shows every public catalog feature side-by-side across all plans, with the current plan column highlighted and clicking any header opens the upgrade preview.","body":"Phase L.5 Phase E4 of the plans rework. Closes spec §5.4. Mirrors\nthe operator-side matrix from Phase C1, scoped to the tenant\naudience.\n\n**What changed.**\n\n`/settings/billing` \"Available plans\" card gains a Cards |\nMatrix view-mode toggle in its header. Choice persists in\n`localStorage` so each tenant's pick survives navigation.\n\n**Cards** (default) — unchanged layout. Cards stack 2-up on\nmedium screens, 3-up on xl.\n\n**Matrix** — new `TenantPlansMatrix` component:\n\n- Rows: every `publicCatalogEntries()` entry, excluding\n  deprecated.\n- Columns: public plans, sortOrder-ordered.\n- Header cells show plan name + price; the current plan column\n  gets a subtle accent tint + a \"Current\" pill below the price.\n- Clicking any plan header fires the same flow the cards use:\n  current plan → no-op; non-current → opens `UpgradePreviewDialog`\n  (Phase E3) with the side-by-side diff before the subscription\n  change lands.\n- Hover on a feature row title surfaces the catalog\n  `marketingDescription` as a tooltip.\n\nCell rendering:\n- Boolean → ✓ / —.\n- Number → catalog `formatForDisplay` when set, otherwise\n  `toLocaleString()`; null/undefined → `∞`.\n- module_array → not shown (the existing modules-list section\n  above the picker covers it).\n\nDistinct from the operator `PlansMatrix` at `/saas/plans`: this\nmatrix shows only `publicVisible` entries and never edits values.\nTenants pick plans; they don't author them.\n\n**No data migration. No new actions.** Pure-render component\nthat reads the already-fetched `plans` array + the static\n`publicCatalogEntries()` set.\n\nSpec: docs/plans/PLANS_REWORK_SPEC.md §5.4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["knife-claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.177Z","updatedAt":"2026-06-13T19:04:25.177Z"},{"id":"1cc4050e-b747-4f6e-a283-a8a65cf21f13","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-public-rate-limits","type":"security","scope":"support","summary":"Tighter per-IP rate limits on public AI-chat, widget, and contact endpoints.","body":"The anonymous-reachable support endpoints fell under the generic 120/min\nwrite cap, which is far too loose for what they do: each AI conversation\ncall drives an LLM request (provider-credit burn), each widget conversation\ncall creates/appends rows, and the public form actions mint a ticket per\ncall. They now get dedicated buckets — AI `conversation.start`/`.post`\n20/min/IP, widget `conversation.start`/`.post_visitor` 30/min/IP, and the\n`submit_offline_form` / `submit_contact` ticket-minting forms 5 per 10 min\nper IP (the rate the contact form already documented). Complements the\nper-conversation AI turn cap.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T18:29:11.105Z","updatedAt":"2026-06-15T18:29:11.105Z"},{"id":"5acab9dd-af56-4bc3-9d5d-340d35c9fe8e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-5c-hrm-benefits","type":"changed","scope":"web","summary":"The benefits-package create/edit form now uses the unified Form stack.","body":"Form-polish plan, Phase 5c: the HRM benefits-package editor sheet now uses the unified\n`useAppForm` + `Form` stack with Zod validation and inline field errors. All 11 fields keep\ntheir grid layout and custom controls (currency picker, benefits payload editor, active\ntoggle, date fields). Slug stays required-on-create / immutable-on-edit, the employer-cost\ncents conversion and applies-to CSV parsing are unchanged, and the assignments panel keeps\nworking alongside the form.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-08-form-stack-5c-hrm-benefits.md","internalOnly":false,"createdAt":"2026-06-08T16:37:57.682Z","updatedAt":"2026-06-08T16:37:57.682Z"},{"id":"4757fa26-516d-47c6-8376-a3afa0e5099e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-unread-and-notifications-fix","type":"fixed","scope":"chat","summary":"Mark-read fires eagerly on channel-open; desktop notifications stop firing twice + over-nudging.","body":"Two long-standing chat papercuts ship together.\n\n**Unread state didn't always clear when you opened a channel.** The\n`chat.message.mark_read` trigger waited for the message-list HTTP round-trip\nto land before firing. If you opened a channel and navigated (or refreshed)\nbefore the messages query resolved, mark-read never ran and the channel\nstayed unread across refresh + tab restore.\n\nFixed: the channel-view now fires mark-read EAGERLY using the channel row's\n`currentSeq` (high-water seq) the moment the cached channel record resolves\n— no waiting on the message-list query. The messages-driven effect remains\nas a backup for the rare race where a new message lands between the channel\nrefetch and the mark-read fan-out, so we don't lose the leading edge.\n\nFailures (rate limit, transient network) are now caught: the mark-read\nmutation rewinds `markedSeqRef` and invalidates the channels query so the\nsidebar reflects the true server state instead of a sticky optimistic zero.\n\n**Desktop notifications fired multiple times per chat message + kept nudging\nthe OS tray.** Two root causes:\n\n1. The notifications bell fired its own `Notification(title)` on every\n   `notification.new` envelope — even for chat-flavoured kinds\n   (`chat.mention`, `chat.dm.message`, `chat.here`, `chat.channel_mention`)\n   that the chat sidebar already showed via its per-channel tagged\n   notification. Result: every @-mention spawned TWO OS notifications.\n   Now the bell skips native notifications for any `chat.*` kind — the\n   sidebar's per-channel notification is the single source of truth.\n2. The chat sidebar's `Notification` used `renotify: true`, which makes the\n   OS re-flash and re-play the alert sound on every new message even though\n   the tag-coalesced entry is already visible. Removed: the first message in\n   a quiet channel still pings, subsequent messages in an active channel\n   silently update the same OS entry instead of nudging you again.\n\nNon-chat notifications (workflow approvals, payroll alerts, leave decisions,\netc.) keep going through the bell's native-notification path — those have no\nsidebar surface to deduplicate against.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.241Z","updatedAt":"2026-06-13T19:04:25.241Z"},{"id":"00213d5d-4cf7-4553-b048-00bf0b4acf40","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbox-calendar-view","type":"added","scope":"support","summary":"The support inbox gains a Calendar view showing tickets on a month grid by the day they were filed.","body":"The agent inbox now has a **Calendar** view: a month grid placing each\nticket on the day it was filed, with prev/next/today navigation. Day cells\nshow the tickets (status-tinted, click to open) with a \"+N more\" overflow.\nCompletes the inbox view set (List / Grouped / Board / Calendar), matching\nthe tasks/projects modules. A due-date-based calendar can follow once SLA\ntargets are surfaced in the list.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.555Z","updatedAt":"2026-06-13T19:04:25.555Z"},{"id":"7ccf2f3c-fea5-48fa-8207-0a1450a9b2d5","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-typing-indicator-scoped","type":"fixed","scope":"chat","summary":"Avatars no longer flash \"typing\" everywhere when a user is typing in one channel.","body":"The presence-v2 hub broadcasts a user's typing status with the channel id\nthey're typing in. The frontend was treating that as a global \"this user is\ntyping somewhere\" badge and surfacing it on every avatar that user appeared\non — sidebar member rows, mention popovers, hover cards, profile panes,\nmember lists. With the per-channel `TypingIndicator` already showing\n\"…is typing\" in the actual channel, the secondary badge across the rest of\nthe app was confusing (\"Why does Alice look like she's typing in #design\nwhen I'm in #random?\").\n\n`useUserPresence` now treats typing as **contextual**. Callers that don't\npass a `contextChannelId` never see typing as a status — typing is silently\ndemoted to `active` so the avatar still reads \"online and reachable\" without\nthe misleading typing flourish. Callers that DO pass a context channel id\n(future per-channel surfaces, e.g. a \"live in this room\" tile) only see\ntyping when the user is typing in THAT channel.\n\nEnd result: typing is now visible only on the per-channel `TypingIndicator`\nmounted inside the channel-view, matching the user's expectation — single\nsource of truth for \"who is typing where\". The fix is a one-line behaviour\nchange at the hook level; no caller updates required, no schema changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.720Z","updatedAt":"2026-06-13T19:04:25.720Z"},{"id":"c6c8aa62-b9c2-4b27-94ca-52fcb783b5e0","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-5c-engagement","type":"changed","scope":"web","summary":"The engagement edit sheet now uses the unified Form stack.","body":"Form-polish plan, Phase 5c: the client engagement edit sheet now uses the unified\n`useAppForm` + `Form` stack with Zod validation and inline field errors. The lifecycle\nfields (name, dates, MRR/TCV, description, notes) keep their grid layout, and the\ntype-specific metadata sub-form is bound as a single field so every engagement-type\nvariant keeps working. Money cents conversion, server-side field-error mapping, and\ncache invalidation are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:47:06.292Z","updatedAt":"2026-06-13T08:47:06.292Z"},{"id":"060eb64e-0900-4599-beb2-7cd5842bb5e8","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-5c-job-create","type":"changed","scope":"web","summary":"The job create/edit sheet now uses the unified Form stack.","body":"Form-polish plan, Phase 5c: the recruitment job create/edit sheet — the app's most complex\nform (~30 fields, screening-question editor, section nav, multiple pickers) — now uses the\nunified `useAppForm` + `Form` stack. The slug auto-derive, required/slug/salary/questions\nvalidation gates, per-section completeness dots, edit-vs-create payload handling, and the\nquestions editor all behave exactly as before; validation now also surfaces inline.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:58:25.411Z","updatedAt":"2026-06-13T08:58:25.411Z"},{"id":"9935d96f-75b7-4602-9fe0-adbe93d000f2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-5b-batch5","type":"changed","scope":"web","summary":"Payroll, expenses-category, custom-field, and webhook forms use the unified Form stack.","body":"Form-polish plan, Phase 5 (forms batch): the edit-pay-group and submit-reimbursement-claim\nforms (payroll), the expense-category editor, the project custom-field admin form, and the\nSaaS webhook form now use the unified `useAppForm` + `Form` stack with Zod validation and\ninline errors. Money conversions, create-vs-update payloads, custom controls (color picker,\ncurrency picker, approval-role chips, checkboxes), and server-error mapping are all preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:22.161Z","updatedAt":"2026-06-13T09:22:22.161Z"},{"id":"675dfc1b-b36a-4ce6-81d3-e5adb6a18863","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-insert-step","type":"added","scope":"crm","summary":"Insert an automation step between existing steps by hovering the connector and clicking +.","body":"You can now **insert a step anywhere** in an automation, not just append at the\nend: hover the connector line between two steps and click the **+** that appears\nto drop a new step at that position (it opens for editing immediately). The\nbottom \"Add step\" still appends. Matches the Zapier/n8n insert-between pattern.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:22.663Z","updatedAt":"2026-06-13T09:22:22.663Z"},{"id":"9aceb52c-9fad-4e1f-a213-25430a16dc5b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-canvas-polish","type":"changed","scope":"crm","summary":"The automation canvas gains a subtle dot-grid and per-type colour-accented node cards.","body":"Visual polish on the automation canvas: a subtle **dot-grid backdrop** so it\nreads as a true canvas, and **richer node cards** — each step card now carries a\nleft **accent rail** and a tinted icon chip in its type colour (create = green,\nAI / email = accent, condition / branch = amber, the rest = info), is wider, and\nlifts on hover. Makes a multi-step automation far easier to scan at a glance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:22.111Z","updatedAt":"2026-06-13T09:22:22.111Z"},{"id":"69617de9-430f-45d1-856b-77459692074e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-5b-batch6","type":"changed","scope":"web","summary":"Payroll recurring/schedule, AI routing, changelog, and support-reply forms use the Form stack.","body":"Form-polish plan, Phase 5 (forms batch): the recurring-payroll-item and pay-schedule /\npay-group dialogs (payroll), the AI routing-rule editor, the changelog release + entry\neditors, and the support agent reply/note form now use the unified `useAppForm` + `Form`\nstack with Zod validation and inline errors. Money conversions (flat cents vs percent basis\npoints), frequency-conditional fields, canary null-clearing, provider→model autofill, and\ncreate-vs-update payloads are all preserved; filters and row actions are left untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:22.112Z","updatedAt":"2026-06-13T09:22:22.112Z"},{"id":"7f954e61-e100-46d9-845b-f17d5169da64","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-canvas-redesign","type":"changed","scope":"crm","summary":"The automation builder is now a full-width canvas with edge-docked Workflows and config panels, fully responsive.","body":"The CRM automation builder was reworked from a cramped three-column grid into a\n**full-width canvas**:\n\n- The node flow now uses the **entire width** in a scrollable canvas.\n- The **Workflows list** slides in from the **left edge** (toggle in the toolbar)\n  and the **node config** docks to the **right edge** when you select a step —\n  both overlay the canvas instead of permanently squeezing it, each with a close\n  button.\n- A single top toolbar carries the Workflows toggle, name, and run/save actions.\n- **Responsive:** on small screens the panels become tap-to-close drawers\n  (capped at 85–90vw) and toolbar action labels collapse to icons.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:22.145Z","updatedAt":"2026-06-13T09:22:22.145Z"},{"id":"e957c055-bb54-4bad-b954-60e5a585217b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-attachments","type":"added","scope":"roadmap","summary":"Added image / file attachments on roadmap feature requests + admin authoring.","body":"User-requested: roadmap features can now carry up to 5 images / files\neach. End-to-end flow: drop on the submit form → presigned upload to\nobject storage → bytes streamed via the existing `/api/files` proxy.\n\n**Schema:**\n\n- `platform_roadmap_features.attachments` (jsonb, default `[]`) —\n  array of `{ id, key, filename, contentType, sizeBytes, uploadedAt }`.\n- Migration `0256_0257_roadmap_attachments` — idempotent\n  `ADD COLUMN IF NOT EXISTS`.\n\n**New action:**\n\n- `platform.roadmap.feature.create_upload_url` — gated by\n  `roadmap:feature:submit` (only actors who can author features can\n  burn presigned URLs). Mints a short-lived PUT URL for direct browser\n  upload to object storage. Validates content type against the storage\n  allowlist + size against `MAX_UPLOAD_BYTES` (25 MB). Returns the\n  attachment metadata the caller passes through in `feature.submit` /\n  `feature.create`'s `attachments[]` field.\n\n**Schema extensions:**\n\n- `SubmitFeatureInput` + `CreateFeatureInput` now accept an optional\n  `attachments[]` (max 5, validated at the action layer).\n- `FeaturePublicDto` returns each attachment as `{ id, filename,\n  contentType, sizeBytes, uploadedAt, proxyUrl }` — the raw storage\n  `key` never crosses the wire; the UI uses the `/api/files/<key>`\n  redirect so the bucket stays private.\n\n**Files proxy:**\n\n- `_platform/roadmap/` added to `apps/web/src/server/files-proxy.ts`\n  `PUBLIC_PREFIX` regex. Anonymous visitors on the marketing apex\n  `/roadmap` can render image attachments without authentication.\n\n**Submit form** (`/help/roadmap/new`):\n\n- New \"Attachments\" field with drag-and-drop + browse, image\n  thumbnails for image MIME types, file-name + size for everything\n  else. Removes preview URL on unmount (no leak). Submit blocked while\n  any upload is in flight.\n\n**Detail page** (`/help/roadmap/<slug>`):\n\n- New \"Attachments · N\" section beneath the body. Images render\n  inline (`<img loading=\"lazy\">`); other files render as a download\n  card. Every attachment links to the `/api/files/...` proxy URL\n  which 302-redirects to a fresh presigned GET so the bucket URL\n  never leaks into the page.\n\n**Storage key shape:** `_platform/roadmap/<attachmentId>/<safeName>`.\nRandom per attachment so two features with the same filename never\ncollide.\n\n**Tests:** existing 52 / 52 module tests still pass; the schema\nextension is backward-compatible (existing features default to\n`attachments: []`). Typechecks clean across `@helios/roadmap`,\n`@helios/db`, `apps/web`.\n\n`@helios/storage` added as a workspace dep on `@helios/roadmap`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.306Z","updatedAt":"2026-06-13T19:04:25.306Z"},{"id":"975f24b4-2795-4249-8c0b-0b13e2d4ba27","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-canvas-zoom","type":"added","scope":"crm","summary":"Zoom the automation canvas out (down to 50%) to see a long flow at a glance.","body":"The automation canvas gains a **zoom control** (bottom-right): zoom out in 10%\nsteps down to 50% to fit a long automation on screen, with a tap-to-reset\npercentage readout. Capped at 100% — scaling down never clips, so the whole flow\nstays reachable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:22.676Z","updatedAt":"2026-06-13T09:22:22.676Z"},{"id":"0608f61b-ecc4-49aa-a01e-acedde3e51d5","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-5b-batch7","type":"changed","scope":"web","summary":"Recurring-invoice, subscription, team, and service-inquiry forms use the Form stack.","body":"Form-polish plan, Phase 5 (forms batch): the recurring-invoice template and subscription\ncreate forms (sales), the team create form, and the public service-inquiry form now use the\nunified `useAppForm` + `Form` stack with Zod validation and inline errors. Line-item mapping\n(quantity → basis points, price → cents), client→payment-terms seeding, derived currency,\nand create payloads are preserved; the credit-notes list page (filters + bulk actions, no\ncreate form) was correctly left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:10.498Z","updatedAt":"2026-06-13T10:33:10.498Z"},{"id":"3fe3ab6c-fed3-4843-860e-1a7d44869696","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-performance","type":"changed","scope":"web","summary":"The HRM goal and feedback create forms use the unified Form stack.","body":"Form-polish plan, Phase 5: the HRM performance \"add goal\" and \"add feedback\" create dialogs\nnow use the unified `useAppForm` + `Form` stack with Zod validation and inline errors. Field\npayloads (title/description/cycle/visibility; recipient/kind/body) and the goal-progress row\naction are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:10.492Z","updatedAt":"2026-06-13T10:33:10.492Z"},{"id":"6ed493cc-c9b8-43a2-a1f0-a1997eab8201","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-config-pane-polish","type":"changed","scope":"crm","summary":"Tidy the automation builder's config pane — drop the duplicated heading and even out field spacing.","body":"Polished the automation builder's right-side config pane: the trigger/step\nconfig no longer renders a second \"Trigger\"/\"Step\" heading under the panel's own\ntitle (the duplicate is gone), and field spacing is evened out for a calmer form\nrhythm.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:10.507Z","updatedAt":"2026-06-13T10:33:10.507Z"},{"id":"e5f4674d-5436-485d-87f1-65ceb5d26fb2","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-step-timing","type":"added","scope":"crm","summary":"Automation run detail now shows how long each step took to run.","body":"The CRM automation engine now records per-step execution time, and the run-detail\ninspector shows it next to each step (e.g. \"send email · 1.2s\"). Makes it obvious\nwhich step in a multi-step automation is the slow one. Timing rides in the\nexisting per-step run log — no schema migration.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:11.180Z","updatedAt":"2026-06-13T10:33:11.180Z"},{"id":"713f3901-5269-4d04-8c76-d157cec88998","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"plans-rework-phase-d1-marketing-matrix","type":"changed","scope":"marketing","summary":"Public pricing-page comparison table now sources rows from the new `publicCatalogEntries()` helper — only entries flagged `publicVisible: true` surface, with `marketingLabel` / `marketingDescription` driving the row label + hover tooltip.","body":"Phase L.5 Phase D1 of the plans rework. First marketing-side\nconsumer of the catalog v2 metadata shipped in L.5 Phase A1.\n\n**What changed.**\n\n`apps/marketing/src/components/page-templates/pricing-page-template.tsx` —\n`buildComparisonRows()` now filters via `publicCatalogEntries()`\n(the helper that returns only entries flagged `publicVisible:\ntrue`). Pre-L.5 the page surfaced every non-module catalog entry\nunconditionally — which meant admin-only knobs like\n`audit_retention_days`, `hipaa_mode`, the new L.5 `rateLimits`,\nand the L.5 `permissionGates` could potentially leak onto the\npublic matrix once they got back-filled with `defaultsByPlan`\nvalues.\n\n**Row labels** now follow the catalog v2 priority:\n`marketingLabel` (operator-edited prospect copy) →\n`COMPARISON_LABEL_OVERRIDES` (legacy hard-coded map, kept as\nfallback) → editor `label`. Operators editing the catalog can\nchange customer-facing pricing copy without touching marketing\ncode.\n\n**Row hints.** `marketingDescription` now flows into a new\n`hint?: string` field on `ComparisonRow` (in\n`apps/marketing/src/components/blocks/comparison-table.tsx`).\nThe comparison table renders it as a tooltip on the feature\ncell + a dashed underline indicating \"more info on hover\".\n\n**Deprecated entries excluded.** The L.5 Phase A1 backfill\nadded `deprecated: true` to `custom_branding`. The new builder\ndrops deprecated entries entirely so the public pricing page\nnever advertises a flag a prospect can't actually buy. The\nadmin editor still surfaces deprecated entries with the\n\"deprecated\" pill (per L.4 Phase J + L.5 Phase C1 patterns).\n\n**Back-compat.** When zero entries are flagged\n`publicVisible` (e.g. a catalog snapshot that pre-dates the L.5\nback-fill), the builder falls through to the historical \"every\nnon-module + non-deprecated\" set so the pricing page never\ngoes blank. Fail-safe — the pricing page works on every\ncatalog version.\n\n**No data migration.** The L.4 branding + L.5 catalog entries\nthat were back-filled with `publicVisible: true` (branding,\nwhitelabel, custom_domains, email_branding) start surfacing\ntheir `marketingLabel` immediately. Catalog entries that\nweren't back-filled continue to fall through `COMPARISON_LABEL_OVERRIDES`\nunchanged.\n\n**Pre-existing typecheck failures** in `src/pages/roadmap.astro`\nare parallel-session WIP, untouched.\n\nSpec: docs/plans/PLANS_REWORK_SPEC.md §4.1.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.491Z","updatedAt":"2026-06-13T19:04:25.491Z"},{"id":"8819f063-03da-4293-af1e-6b7b970207cb","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-inbox-grouped-view","type":"added","scope":"support","summary":"The support inbox gains a view switcher and a Grouped view (by status or priority).","body":"The agent inbox now has a List/Grouped **view switcher** (matching the\ntasks module), persisted per browser. The new **Grouped** view buckets\ntickets into collapsible sections by status or priority — chosen with a\ngroup-by toggle — each section showing a count and the same ticket rows as\nthe list. Board (Kanban) and Calendar views follow next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.555Z","updatedAt":"2026-06-13T19:04:25.555Z"},{"id":"8390d683-395d-4bbb-8e08-0778dc552674","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-react-flow-canvas","type":"changed","scope":"crm","summary":"Automation builder is now a freeform canvas — drag nodes, pan with the hand cursor, and scroll/pinch to zoom.","body":"Rebuilt the CRM automation builder's canvas on React Flow. The trigger and each\nstep now render as draggable nodes on a freeform, full-width canvas: pan with the\nhand cursor, scroll or pinch to zoom in and out, and reposition nodes anywhere —\nwith a minimap and zoom controls. Node positions persist in the workflow so your\nlayout is there next time you open it. The edge-docked Workflows list and node\nconfig panels are unchanged; \"Add step\" moved up into the toolbar.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:11.178Z","updatedAt":"2026-06-13T10:33:11.178Z"},{"id":"90f4db45-6ef1-4f40-a396-79e8f3d58d5d","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"automations-list-default","type":"changed","scope":"crm","summary":"CRM Automations now opens to a list of your automations; click one to open the builder.","body":"The CRM Automations page now defaults to a clean list of your automations —\neach row showing its name, status, run count, and last-updated date. Clicking a\nrow (or \"New automation\") opens the unified freeform builder; a \"Back\" button in\nthe builder returns to the list, prompting first if you have unsaved edits. The\nold slide-in workflows panel is gone in favour of this dedicated list surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:39.903Z","updatedAt":"2026-06-13T13:25:39.903Z"},{"id":"6e3cfa55-10eb-455c-a233-d86f4491e71b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-multiform-hrm-payroll","type":"changed","scope":"web","summary":"Leave, roster, and all pay-code editor forms use the unified Form stack.","body":"Form-polish plan, Phase 5: the leave request + reject-with-reason forms, the roster create +\ncopy-day forms, and all three pay-code editors (earning, deduction, tax — including the\nprogressive tax-bracket editor) now use the unified `useAppForm` + `Form` stack with Zod\nvalidation and inline errors. Money/rate handling (deduction default value, tax rate basis\npoints, bracket cleaning/sorting, wage cap), code uppercasing + immutability, and create/\nupdate payloads are all preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:39.904Z","updatedAt":"2026-06-13T13:25:39.904Z"},{"id":"680b8c69-0ef0-4474-a7ad-da0f522c1ef4","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-client-space-action","type":"added","scope":"chat","summary":"Client Spaces C2/C3 — chat.client_space.find_or_create + kind gates on list/get.","body":"The gated client-communications tier becomes operational. Three pieces:\n\n- **`chat.client_space.find_or_create`** — idempotent action that\n  returns the existing client space for a `(org, client)` pair or\n  creates one in a transaction. Forces `visibility='secret'` and\n  `kind='client'` (both enforced by CHECK constraints from\n  `c1b408f8`). Seeds the creator as space-admin + creates a\n  `chat_space_settings` row. Requires `chat:client_space:create` —\n  not satisfied by `chat:admin` per spec D6.\n- **`chat.space.list` kind gate** — non-permitted users now see zero\n  client spaces in their list. The gate fires in both the member-row\n  query (`memberSpaces`) and the browse query (`browseSpaces`).\n  Permitted users see them seamlessly mixed with their team spaces.\n- **`chat.space.get` kind existence-leak guard** — `kind='client'`\n  rows return `not_found` (not `policy_denied`) to non-permitted\n  users so error-code probing can't infer a client space exists.\n\nThe gate is `chat:client_space:read`. `chat:admin` does NOT grant it\n— client confidentiality is a separate axis from chat moderation.\nPermission is assignable only via direct grant (Settings → Users →\nPermissions) or a custom role like \"Account Manager\"; never in any\nSTANDARD_ROLE_BLUEPRINT.\n\n175 chat tests still pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.155Z","updatedAt":"2026-06-13T13:25:40.155Z"},{"id":"a4db145a-09cf-4e47-b1ae-0072773e449e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"help-defaults-to-platform","type":"changed","scope":"support","summary":"The public help center now defaults to the platform help center instead of a fixed org.","body":"Visiting `/help` (and its KB, docs, services, contact, and changelog\nsub-pages) now shows the **platform** help center by default, rather than a\nhard-coded seed organization. A specific tenant's help is reached by passing\n`?org=<slug>` (a verified subdomain and a `/help/o/<slug>` path form follow).\nInternal platform links no longer carry an `?org=` parameter, and the\nhard-coded brand slug was removed from the routes. Driven by the new\nsurface-tier resolver. See docs/plans/SUPPORT_PLATFORM_VS_ORG_SPEC.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.854Z","updatedAt":"2026-06-13T13:25:40.854Z"},{"id":"425b6c93-b044-48b9-9149-f9ab6dd4b8d4","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-link-unfurl-card","type":"added","scope":"chat","summary":"Slack-style link preview card + useLinkPreviews hook for chat messages.","body":"UI half of the `chat.message.list_link_previews` action — the\nbackend cache landed earlier; this adds the renderer.\n\n- **`<LinkUnfurlCard />`** — renders the first OG-resolved URL on a\n  chat message as a Slack-style preview (image left, text right) with\n  skeleton-while-pending, author-only hide affordance, motion-reduce\n  respect, and graceful degradation when the cache returns\n  `no_metadata`.\n- **`useLinkPreviews(messageId)`** — TanStack Query hook keyed by\n  message id with a 60s stale time matching the cache resolve\n  cadence. Exposes the `PreviewRow[]` for downstream renderers (chip,\n  multi-card future).\n\nNot yet wired into `message-item.tsx` — that integration commit lands\nseparately. Both files are unimported so this commit ships dead-code\nready for the next round (`Grep` confirms zero consumers). Both have\nJSDoc explaining the consumer contract.\n\nThis commit was authored by the unfurl-renderer workflow\n`wf_c3f81423-a0a`; the workflow's other phases (ephemeral messages,\nread-path filters) rate-limited mid-run and will land separately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.157Z","updatedAt":"2026-06-13T13:25:40.157Z"},{"id":"80f4fe30-4231-4847-9e12-38574ddbbcff","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-settings-and-client-kind","type":"added","scope":"chat","summary":"Chat Spaces Phase 1.B schema + Client Spaces C1 — chat_space_settings table, kind enum, client_id FK, leave + client-space permission keys.","body":"Two schema shifts in one migration after user sign-off on the spec\nrecommendations (`docs/plans/CHAT_SPACES_SPEC.md` §11 + `docs/plans/\nCHAT_CLIENT_SPACES_SPEC.md` §10):\n\n- **`chat_space_settings`** table (Phase 1.B). Per-space typed\n  configuration pulled out of the `chat_spaces.settings` jsonb so\n  the structured knobs get typed columns: `default_notification_level`\n  (mentions by default), `default_retention_days`, `allow_dms`,\n  `ai_summary_enabled`, `external_invites_allowed`. One row per space;\n  backfilled for every existing space + created in\n  `chat.space.create`'s transaction going forward.\n- **`chat_space_kind` enum** + **`chat_spaces.kind` / `client_id`\n  columns** (Client Spaces C1). Distinguishes `team` (default) vs\n  `client` spaces. Two CHECK constraints enforce: kind='client' iff\n  client_id IS NOT NULL, and kind='client' forces visibility='secret'.\n  Per-(org, client) unique index ensures one client space per\n  customer. `client_id` FKs to `crm_companies` (Helios's canonical\n  client record).\n\nPermission keys added:\n\n- `chat:space:leave` — every member gets it via SELF_CHAT blueprint;\n  handler-layer guards refuse last-admin + default-space removal.\n- `chat:client_space:read` / `:create` / `:manage_members` / `:delete`\n  — deliberately NOT in any STANDARD_ROLE_BLUEPRINT. Assignable only\n  via direct grant (Settings → Users → Permissions) or a custom\n  role (e.g. \"Account Manager\"). Owners get them via the root\n  all-permissions set.\n\nNo action / UI changes yet — those land in follow-up commits using\nthis foundation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.458Z","updatedAt":"2026-06-13T13:25:40.458Z"},{"id":"2c1d73bd-658a-4e8c-b117-403c18296f49","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-space-member-actions","type":"added","scope":"chat","summary":"Chat Spaces Phase 1.B — leave / list_members / update_member_role / update_last_visited actions.","body":"Four Phase 1.B member-management actions on top of the `c1b408f8`\nschema foundation. These complete the actions named as gaps in the\nspec synthesis (`docs/plans/CHAT_SPACES_SPEC.md` §7).\n\n- **`chat.space.leave`** — self-service membership removal. Refuses\n  the default space (auto-join contract) and last-admin removal\n  (would orphan management). Idempotent for non-members. Cascades\n  the actor's removal across every channel inside the space in one\n  transaction. Granted to every member via the SELF_CHAT blueprint\n  through the new `chat:space:leave` permission.\n- **`chat.space.list_members`** — paginated read of a space's\n  members with their role, joined_at, and hydrated display name +\n  email from `users`. Cursor by joined_at ascending. Actor must be\n  a space member (or hold `chat:admin`).\n- **`chat.space.update_member_role`** — change a member's role\n  (member / admin / guest). Requires `chat:space:manage_members`\n  AND space-admin role (or `chat:admin`). Refuses last-admin\n  demotion — promote someone else first.\n- **`chat.space.update_last_visited`** — tiny write-only crumb\n  called from the route loader on every space landing. Drives the\n  switcher's sticky \"last visited\" precedence so users return to\n  where they were. No-op when the actor isn't a member; no\n  existence-leak (404 if the space is gone).\n\n175 chat tests pass. Tests for the four new actions land in a\nfollow-up commit.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.457Z","updatedAt":"2026-06-13T13:25:40.457Z"},{"id":"a72350f6-314a-4dbb-ad9b-31b5d99fe07e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-quotations-detail","type":"changed","scope":"web","summary":"The quotation detail edit, share, and send-email forms use the unified Form stack.","body":"Form-polish plan, Phase 5: all three forms on the quotation detail page — the draft line-item\neditor, the share-link creator, and the send-by-email composer — now use the unified\n`useAppForm` + `Form` stack with Zod validation and inline errors. The line-item editor keeps\nits `LineItemsEditor` + the \"at least one valid line\" gate, the shared line→payload conversion\nis unchanged, and the row actions (send/accept/decline/convert/download/revoke) are untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.603Z","updatedAt":"2026-06-13T13:25:40.603Z"},{"id":"06c03efa-fd74-4b75-8006-7ec47c8c2aee","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"form-stack-quotations-list","type":"changed","scope":"web","summary":"The create-quotation and row send-email forms use the unified Form stack.","body":"Form-polish plan, Phase 5: the create-quotation sheet (line-item editor) and the per-row\nsend-by-email composer on the quotations list page now use the unified `useAppForm` + `Form`\nstack with Zod validation and inline errors. The `companyId` wire field, currency-follows-client\nbehaviour, shared line→payload conversion, and the \"client + at least one valid line\" gate are\npreserved; list filters, multi-select, and row/bulk actions are untouched. Completes the\nquotations cluster (list + detail).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.615Z","updatedAt":"2026-06-13T13:25:40.615Z"},{"id":"5dd6ac71-5bdd-44ed-8f9c-f1b43df345e7","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"effective-branding-wave-3-email","type":"changed","scope":"email","summary":"Email chrome now plan-gates org branding — free-tier tenants get platform logo + colors with a \"Workspace: {orgName} · Powered by {platform}\" footer; identity (legal name, support email, address) stays org-owned.","body":"Phase L.3 wave 3.1 of the signup-hardening initiative. Extends the\nwave-1 / wave-2 plan-gated branding contract\n([[effective-branding-resolver]], [[effective-branding-wave-2]])\nto the email-chrome surface.\n\n**What changed.** `resolveBrandingVars()` in\n`modules/email/src/lib/branding-context.ts` previously cascaded\nevery brand field (logo, primary, accent, tagline) org → platform\nunconditionally. After wave 3.1, the resolver consults\n`hasFeature(orgId, 'custom_branding')` and switches the chrome:\n\n- **`mode='org'`** (plan includes `custom_branding` AND org has\n  any brand asset) — chrome renders the org's logo, primary,\n  accent, tagline. Same as before.\n\n- **`mode='platform'`** (free tier, downgraded plan, or org\n  uploaded nothing) — chrome falls back to platform values.\n  `orgLogoUrl` is emptied so the existing `{{#orgLogoUrl}}…\n  {{^orgLogoUrl}}{{#appLogoUrl}}…` cascade in `_shared.ts`\n  drops to the platform logo automatically.\n\nCrucially, **identity stays org-owned in both modes**:\n`orgName`, `orgLegalName`, `address`, `country`, `taxId`,\n`supportEmail`, `supportPhone`, `socialLinks` — the recipient\nstill gets the right business contact info, the right legal\nentity on receipts, the right reply-to. Only the visual chrome\nswitches.\n\n**New template variables** (auto-injected into every render):\n\n- `brandingMode: 'org' | 'platform'` — raw discriminator.\n- `isPlatformBranded: boolean` — mustache section-block trigger.\n- `isOrgBranded: boolean` — opposite.\n- `poweredByName: string` — platform name for \"Powered by\"\n  line. Empty in org mode AND on self-hosted deployments with\n  no platform `appName`.\n- `poweredByUrl: string` — marketing URL for the credit link.\n\n**Shared chrome (`_shared.ts`) updated:**\n\n- Old: `Powered by {{ appName }}` inline on the © line, always\n  fired when `appName` existed — leaked the platform credit\n  into paid-tier tenant emails.\n- New: Powered-by line guarded by `{{#isPlatformBranded}}` so\n  it surfaces ONLY on plan-fallback sends. The line is\n  `Workspace: {{ orgName }} · Powered by {{ poweredByName }}`\n  matching the design spec.\n\nThe plan-gate query is fail-open: if `hasFeature` throws\n(degraded plan-read) the resolver returns `mode='org'` when the\norg has assets — same shape as before. A broken plan-tables\nquery never blocks an email send.\n\n**Tests.** 6 new cases on the gated cascade:\nmode='org' / downgrade / hollow-wordmark / fail-open /\nno-appName / hasFeature-throws.\n\n**Wave 3 remaining:** document letterhead\n(`packages/documents`), careers portal\n(`apps/web/src/routes/careers.tsx`), OG cards. Those still need\nvisual QA on rendered output before they swap to the resolver.\n\n**Lock-file note.** The `pnpm-lock.yaml` diff carries two\nadditions: the `@helios/saas` workspace link added to\n`modules/email/package.json` (mine), and a `modules/roadmap`\nworkspace registration left in the local lock by a parallel\nsession (their source is `??` untracked, harmless pre-staging).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.715Z","updatedAt":"2026-06-13T13:25:40.715Z"},{"id":"f5ca19c0-4e90-43cb-b46b-9582df92a2ec","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-template-library-expansion","type":"added","scope":"crm","summary":"Added 13 ready-to-use automation templates (lead drips, deal routing, win-back, AI triage and more).","body":"The CRM automation templates gallery grew from 4 to 17 one-click starters,\ncovering real-world plays across lead nurture, deals, onboarding, and AI:\n\n- **Lead nurture** — 3-touch welcome drip, qualified-lead handoff, AI lead triage.\n- **Deals** — new-deal BANT qualification task, a stage router (branches by the\n  new stage), deal-won onboarding pack (with a delayed welcome-pack task), and a\n  90-day lost-deal win-back.\n- **AI** — deal status updates on stage change, discovery questions on new deals,\n  a win-back email draft on lost deals, and a company research brief.\n- **Onboarding** — welcome email for new contacts, account-plan task for new\n  companies.\n\nEach template is built only from supported node types and the real event-payload\nfields, so it instantiates into a working draft you can review and activate. A\nnew test suite validates every template against the live workflow schema.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.837Z","updatedAt":"2026-06-13T13:25:40.837Z"},{"id":"47305aac-c37d-44cb-a882-bdbbb0dc973d","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"lead-created-event-enrichment","type":"changed","scope":"crm","summary":"New-lead automations can now personalize and filter on the lead's name, company, source, and score.","body":"The `crm.lead.created` event now carries the lead's `firstName`, `lastName`,\n`companyName`, `source`, and `score` alongside its id and email. That means\nautomations triggered on new leads can personalize with `{{ firstName }}` /\n`{{ companyName }}` and gate with trigger filters like `{{ score }}` greater than\n80 or `{{ source }}` equals \"web\" — no extra lookup needed. The existing\nwelcome-lead, hot-lead-alert, and AI follow-up templates now resolve these\nfields for real. Fields are optional on the event, so existing subscribers are\nunaffected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:40.855Z","updatedAt":"2026-06-13T13:25:40.855Z"},{"id":"5e95631f-f6e0-48a2-a820-46454a9a15f9","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"workflow-trigger-filters","type":"added","scope":"crm","summary":"Event-triggered automations can now run only when conditions on the event match (e.g. stage = Won).","body":"CRM automations with a record-event trigger can now carry filter conditions, so\nthey fire only when the event matches — e.g. \"on deal stage changed, only when\n{{ stageName }} equals Won\". Add one or more conditions in the trigger panel and\nchoose match-all (AND) or match-any (OR); with no conditions the automation runs\non every matching event as before. Conditions are evaluated against the event's\nfields before the run starts; no schema migration.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:41.098Z","updatedAt":"2026-06-13T13:25:41.098Z"},{"id":"2c2a8faa-83a0-463a-bf93-7a1f88fd572d","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"support-link-target-picker","type":"changed","scope":"support","summary":"Linking a ticket to a client item now uses a searchable picker instead of pasting an id.","body":"The ticket detail \"Linked items\" block no longer asks agents to paste an\nentity UUID — picking the entity type (project / subscription / invoice /\nproduct / engagement) now reveals a searchable picker of that type's\nrecords (by name), so links are made by choosing the actual item. The\npicker over-fetches and filters client-side, resetting when the entity\ntype changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:25.580Z","updatedAt":"2026-06-13T19:04:25.580Z"},{"id":"710ca897-b149-4fb0-aa0f-e3fff7cdfe68","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-avatars","type":"fixed","scope":"chat","summary":"Chat surfaces now render real profile pictures from users.image; falls back to initials only when no image is set.","body":"The chat module's `<Avatar>` calls were only passing `name=`, so every\nrow across messages, DM sidebar entries, members list, mention picker,\nnew-DM modal, channel header, and the mention candidates always\nrendered initials — even when the user had a profile picture set.\nRoot cause: the actions that hydrated author / member / counterpart\ndisplay data never selected `users.image`. The Avatar primitive\nalready supported `src`; it just wasn't being given one.\n\nFixed end-to-end across seven actions + their schemas + ten frontend\ncomponents:\n\n**Actions extended to select `users.image`:**\n\n- `chat.message.list` — message row now carries `authorImage`.\n- `chat.thread.list_messages` — same.\n- `chat.message.list_pinned` — same.\n- `chat.user.search` — search result rows now carry `image`.\n- `chat.user.lookup_bulk` — bulk lookup rows now carry `image`.\n- `chat.channel.list_members` — member rows now carry `image`.\n- `chat.channel.list` — DM rows now carry `dmCounterpartImage`.\n\n**Frontend components passing `src` to `<Avatar>`:**\n\n- `message-item.tsx` — every message row's author avatar.\n- `chat-channels-sidebar.tsx` — DM rows in the sidebar.\n- `channel-view.tsx` — DM header avatar.\n- `members-popover.tsx` — both member-list rows and add-member picker.\n- `mention-popover.tsx` — @-mention picker rows.\n- `composer-tiptap.tsx` — feeds `image` into `UserMentionCandidate`.\n- `new-dm-modal.tsx` — user picker rows.\n- `types.ts` — `ChatMessage.authorImage: string | null` added.\n- `ai-thread-pane.tsx` / `thread-pane.tsx` — optimistic message\n  construction sites updated to set `authorImage: null` per the new\n  required field.\n\nSchemas updated: `ChannelRow.dmCounterpartImage`, `MessageRow.authorImage`,\n`MemberRow.image`, `SearchUsersOutput.items[].image`,\n`UserLookupBulkOutput.items[].image`.\n\n`employees` / `clients` aren't separate avatar tables — they all map\nto `users.image` via the `users` row that backs the membership. So\nthe fix covers all three populations at once.\n\n194 chat tests still pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:41.098Z","updatedAt":"2026-06-13T13:25:41.098Z"},{"id":"63bd7e77-c93f-4efb-a7af-8658648f8b11","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-dm-hardening-pass","type":"security","scope":"chat","summary":"DM channels now refuse member-add, archive, delete, and cross-org peer DMs.","body":"Audit of the DM action surface uncovered four gaps where DM channels behaved\nlike ordinary channels and could be silently mutated in ways that broke the\n\"true DM\" mental model. All are now closed.\n\n**1. `chat.dm.open` validated the peer existed in the global `users` table but\nNOT that they were a member of the actor's org.** A malicious actor with a\ntarget user id could open a DM channel scoped to their org against someone\nwho has never been an org member there — the DM would surface in the\nactor's sidebar with the peer's name + avatar, leaking identity / presence /\nactivity hints across the org boundary. The action now joins `memberships`\nand requires `status='active'`. Suspended peers also can't receive new DMs.\nThe failure code is `not_found` (not `policy_denied`) to avoid a user-\nenumeration vector.\n\n**2. `chat.channel.add_member` accepted DM and group_dm types.** Anyone in a\nDM could add an arbitrary org-member as a third participant, turning a 2-\nperson private room into a multi-party leak vector — the added user would\nsee the entire prior history. The action now refuses dm / group_dm with a\nclear \"open a new DM / create a channel\" message.\n\n**3. `chat.channel.archive` accepted DMs.** Anyone with the archive\npermission could freeze a private 2-person conversation they weren't part\nof. Archive is a channel-lifecycle concept; for DMs the right UX is per-user\nhide-from-sidebar. Refused.\n\n**4. `chat.channel.delete` accepted DMs.** Same shape as archive — admins\nwith chat:channel:delete could nuke a private conversation they had no\nrelationship to. Refused; moderation cases (legal hold release, GDPR delete)\nbelong on a dedicated audited path.\n\nAll four refusals return `validation_failed` / `not_found` with structured\ndetail so frontends can pattern-match. No schema changes; back-compat at the\naction layer for every legitimate caller (UI never exposes archive/delete on\nDM rows; the bad path was reachable only by hand-crafted action calls).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:10.676Z","updatedAt":"2026-06-15T15:59:10.676Z"},{"id":"2f6011f8-affb-4a09-8e10-88bdad154e8e","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-dm-pin-context-menu","type":"added","scope":"chat","summary":"Right-click a DM → pick a space to pin it to (or \"show across all spaces\").","body":"The hybrid DM scope shipped its backend in the previous commit (`b29c2d78`);\nthis turn wires the user-facing surface. Right-clicking a DM (or group DM)\nrow in the sidebar now surfaces a **Pin to space…** submenu listing every\nspace the actor is a member of. Picking one pins the DM to that space — it\ndisappears from the sidebar in every other space's view while remaining\nfully accessible in the chosen one.\n\nWhen a pin already exists, the menu label flips to **Pinned to space…**\n(with a filled pin glyph) and the submenu adds a **Show across all spaces**\naffordance that clears the pin and returns the DM to org-wide visibility.\n\nMutations are optimistic — the row dis/appears from other space views the\ninstant the user picks; the `chat.dm.pin_to_space` / `unpin_from_space`\nround-trip just confirms. Failures revert the cache and surface a localised\ntoast.\n\nThe submenu only renders for `dm` and `group_dm` rows — channel-style rooms\nkeep the existing context-menu shape. Built on the existing\n`ChannelContextMenu` primitive with a new optional `dmPin` prop, so future\nnon-sidebar surfaces can re-use it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:10.783Z","updatedAt":"2026-06-15T15:59:10.783Z"},{"id":"09567b24-67cd-436c-81d8-1b644564b828","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"chat-per-space-unread-badges","type":"added","scope":"chat","summary":"SpaceMenu shows per-space unread + mention counts; trigger dot signals activity elsewhere.","body":"The space switcher now surfaces unread activity per-space so users see where\nthe noise is without opening the dropdown:\n\n- **Trigger dot.** A small coloured pip next to the active space label\n  lights up when ANY non-active space has unreads. Red flavour when those\n  unreads include @-mentions, module-chat flavour otherwise. Hover for a\n  precise count.\n- **Per-row badges.** Each space row inside the dropdown carries its own\n  count: a red `@N` chip when the space has unread mentions, or a\n  module-chat-tinted `N` chip for plain unreads. Mentions take precedence\n  over messages (Slack semantics — a row with both flavours shows only the\n  mention chip).\n\nComputed client-side in the chat sidebar from the already-cached channel\nlist, so no extra round-trip. Aggregation rules:\n\n- Only joined member channels count.\n- DM / group_dm / external rows are skipped (those are org-wide, surfaced\n  separately in the channels rail).\n- Muted channels contribute zero to plain unreads but still surface their\n  mention count (mute silences noise, not pings).\n- Unscoped legacy channels (`space_id IS NULL`) skip aggregation entirely.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:10.937Z","updatedAt":"2026-06-15T15:59:10.937Z"},{"id":"7567baad-f72d-4cc7-8cc6-7b0910def556","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"help-signed-in-chrome","type":"changed","scope":"web","summary":"Help layout + /help/roadmap header now reflect signed-in state — avatar pill, name, and a sign-in CTA for anonymous visitors.","body":"Directly addresses the user-reported \"doesn't show user as signed in\"\nissue on `/help/*`. Two related UX fixes:\n\n**1. Signed-in chrome on the help layout** (`apps/web/src/routes/help.tsx`):\n\nThe top-right corner of every `/help/*` page now reads `useMe()` and\nrenders one of three states:\n\n- **Loading** — a 24×96px skeleton placeholder so the chrome doesn't\n  flicker between unauthed and authed on first paint.\n- **Signed in** — an avatar pill: 20×20 avatar (image or initial-on-\n  module-color circle) + truncated display name + `↗` glyph. Click\n  navigates to `/dashboard`. Tooltip shows the full \"Signed in as\n  …\" string. Falls back to the email when no display name is set.\n- **Anonymous** — the existing \"Sign in\" link (no regression).\n\n**2. `/help/roadmap` header CTAs honor auth state.**\n\nThe \"Request a feature\" CTA on the board page now branches:\n\n- **Loading** — skeleton placeholder (same fix shape as the detail\n  page sidebar in `b8f1ad02`).\n- **Permitted** — \"Request a feature\" primary button.\n- **Authed but lacking `roadmap:feature:submit`** — the existing\n  amber \"send your idea to your owner/admin/manager\" banner (text\n  tweaked: \"Voting is open to everyone\" → \"**Upvoting** is open to\n  everyone\" to match the new ArrowFatUp icon language from\n  `bcfe37f4`).\n- **Anonymous** — a \"Sign in to request a feature\" CTA wired to\n  `/login?redirectTo=/help/roadmap/new` so they bounce back to the\n  gated submit form after authentication.\n\n**Why this matters:** anonymous and signed-in visitors on the public\nhelp pages previously saw identical chrome. Anonymous users had no\nclear path to participate, and signed-in users got zero confirmation\ntheir session was recognized — which the user reported as confusing\nduring testing. The avatar pill is the universal \"you're logged in\"\naffordance every SaaS surface needs.\n\nTypechecks clean across `apps/web`. No schema or action changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.025Z","updatedAt":"2026-06-15T15:59:11.025Z"},{"id":"2e07706a-38ee-47be-a1ca-cae686f074ef","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"post-l5-review-fix-4-rate-limit-cache-key","type":"fixed","scope":"web","summary":"Rate-limit plan-overrides skip when impersonation is active — `planRateLimitsCache` keyed on the impersonator's `activeOrganizationId` was applying the wrong plan's throughput tweak when the action handler ran in a different org.","body":"Post-L.5 review finding #4. Standalone follow-up to\n[[post-l5-review-hardening]] (which shipped the other five fixes).\n\n**The bug.** `apps/web/src/server/api.ts` `getCachedPlanRateLimits`\nderives the cache key from `session.activeOrganizationId`. That\nvalue is a *hint* used by `requireAuth` (`context.ts`) that may\nfall through to a ranked membership in a different org when the\nhint is stale, AND it is the impersonator's own\n`activeOrganizationId` during impersonation while the action\nhandler executes for the target org.\n\nNet result before this fix: during impersonation the per-action\nrate-limit rule applied for the request was the plan limit of\norg A (the impersonator's cookie context), while the action\nhandler executed for org B (the target). For impersonators\noperating on a different plan tier than the target, the wrong\nrate-limit profile applied to every cookie-authed action call.\n\n**The fix.** `getCachedPlanRateLimits` now consults\n`impersonation_sessions` for the request's session token before\nthe override path. When an active, unexpired impersonation row\nexists, the function returns `null` — the rate-limit check falls\nback to the source-defined baseline rule. Root operating as a\ntarget tenant gets the baseline, not the impersonator's plan\ntweaks nor the target's.\n\nThe change is **conservative**: if the `impersonation_sessions`\ntable query throws (e.g. migration not yet applied) the code\nfalls through to the normal cache path. Better to apply an\noverride than to refuse rate-limiting on a degraded DB. The\nstale-cookie risk (cookie carries an old `activeOrganizationId`\nthe user no longer holds a membership in) is left to time —\nthe per-org cache entry expires within 5 seconds, so a flipped\nplan flag or membership change reconverges quickly.\n\n**Why not key on the resolved principal's orgId.** The auth API\nruns the rate-limit check BEFORE `requireAuth` resolves the\nprincipal, intentionally — that ordering avoids paying the auth\ncost on requests that will rate-limit anyway. Re-ordering the\npipeline would have a non-trivial blast radius across api.ts.\nThe narrower fix (skip overrides during impersonation) closes\nthe only meaningfully broken case without that disruption.\n\n**No new tests.** This is a one-line behavioural guard on a\nhot-path-but-non-test-covered helper; the existing rate-limit\ntests don't exercise impersonation cookies. The integration test\nmatrix is a follow-up.\n\nCloses the last actionable finding from the post-L.5 adversarial\nreview.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.305Z","updatedAt":"2026-06-15T15:59:11.305Z"},{"id":"43b9a6d8-aa6e-431c-98a3-ae201e8f4ded","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"roadmap-review-fixes","type":"fixed","scope":"roadmap","summary":"Fixed the TagsField paste-as-mega-tag bug + closed wiring gaps surfaced by adversarial review of f7087cb5.","body":"A 4-lens adversarial review of `f7087cb5` (use_case + tags + form\npolish) raised 10 findings; 7 confirmed real after verifier voting.\nThis commit closes all of them.\n\n**Confirmed real bugs — fixed:**\n\n1. **TagsField paste-as-one-mega-tag** (major). The placeholder said\n   `csv-export, sales-ops, urgent (Enter to add)`, but pasting that\n   exact string yielded a single merged tag `csv-export-sales-ops-urgent`\n   because the comma split path lived only in `onKeyDown` — which paste\n   events don't trigger. Fix: added an `onPaste` handler + comma\n   detection inside `onChange` that splits the value, slugifies each\n   segment, dedupes against existing tags, honors the 10-cap silently,\n   and leaves the trailing fragment in the input. Also added the\n   `Tag limit reached (10 of 10)` placeholder + supporting hint when\n   capped so the disabled state is discoverable.\n\n2. **Missing `useCase` on `FeaturePublic` in `/help/roadmap` page**\n   (minor). The sibling client mirrors in `roadmap.$slug.tsx` +\n   `saas/roadmap.tsx` were updated; this one was missed. Fix: one-line\n   addition, mirrors the other files' ordering.\n\n3. **`useCase: null` missing in all 6 test fixtures** (nit, latent).\n   The fakeDb-based handler tests pass because `invoke()` doesn't\n   re-validate output via Zod, but the fixtures had drifted from\n   `typeof platformRoadmapFeatures.$inferSelect`. Added `useCase: null`\n   between `body: null` and `category` in all six fixtures\n   (feature.test, public.test, submit.test, merge.test, vote.test,\n   comment.test) — one-line each.\n\n4. **Spec doc drift** (nit). Migration SQL comment said \"see spec\n   update\" but the spec's column block didn't list `use_case`. Added\n   a one-line column entry citing the 2026-06-14 research basis.\n\n**Verifier refuted (no action):**\n\n- `roadmap.new.tsx:256` — concern that \"tags + attachments closures\n  could stale\" was refuted by the `useAppForm` `submitRef` pattern:\n  the ref is reassigned on every render, so the submit invocation\n  always reads the freshest closure. Behavior is correct under React\n  18 semantics.\n- Changelog summary >120 chars was claimed to be a CI blocker; verifier\n  found `scripts/changelog/check.mjs` has an upstream `require` bug\n  that no-ops `validateUnreleased()` — the gate the reviewer cited is\n  silently broken (a separate bug — 25 other entries already > 120 in\n  unreleased without blocking CI).\n- §-number citations were claimed to be unresolvable; verifier found\n  the spec uses ordinal §N references internally and all numbers\n  resolve.\n\n**Partial findings — not actioned this commit:**\n\n- Journal contamination (idx 261 referenced `saas_plans_modules_wildcard`\n  whose .sql file was added 6 minutes later in `8d573876`) — real\n  parallel-sessions rule violation in f7087cb5, but self-healed at\n  current HEAD (`8d573876` landed the missing .sql). Reverting now\n  would break HEAD's drizzle-kit migrate.\n- Silent duplicate-tag clear — verifier downgraded from \"nit\" to\n  cosmetic polish since the chip list above the input shows the\n  existing tag, giving implicit confirmation.\n\nTests: 57 / 57 still pass after fixture additions. Typecheck clean\nacross `@helios/roadmap` + `apps/web`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T15:59:11.614Z","updatedAt":"2026-06-15T15:59:11.614Z"},{"id":"1e13461a-b8d4-4af4-b192-dc57a43e7cba","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"web-pwa-precache-cap-bump","type":"fixed","scope":"web","summary":"Fixed the apps/web build by bumping the PWA precache file-size cap from 5 to 8 MiB.","body":"The main `index-*.js` bundle grew past 6.8 MiB (Tremor + TanStack Table + the recruitment/HRM forms graph + module-loader chunks). Workbox's `injectManifest` plugin defaults to a 2 MiB precache file-size cap and refuses to silently drop oversized assets — the build fails fast as a tripwire.\n\nCap was 5 MiB (from an earlier 2→5 bump); raised to 8 MiB so the primary chunk is precached again. Without precaching, offline PWA boot breaks: the first request after install can't resolve the main bundle from cache.\n\nThe tripwire stays: the next time the chunk grows past 8 MiB, the build fails again and that's the prompt to actually code-split (`manualChunks` / dynamic `import()`) rather than perpetually bumping the cap.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["platform-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:01.906Z","updatedAt":"2026-06-16T13:46:01.906Z"},{"id":"90155dea-b10c-4299-99cd-7e7c83f86245","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"ui-data-state-primitives","type":"added","scope":"ui","summary":"New data-state primitives — QueryState, ErrorState, Num, ListOverflowFooter — plus Stat/DataTable hardening.","body":"UI-redesign foundation (data-oriented components). New shared primitives in `@helios/ui`\nso every data region renders honestly without per-screen handling:\n\n- **`<QueryState>`** — the single front door for an async data region: renders exactly one of\n  skeleton | error+retry | empty(null-safe) | data, with error checked BEFORE empty so a failed\n  load can never silently look like \"no data\".\n- **`<ErrorState>`** — the twin of `<EmptyState>` (same layout/motion) with a Retry action.\n- **`<Num>`** — canonical number display: always `tabular-nums`, locale-grouped, null/NaN-safe,\n  optional compact notation, full-value title tooltip.\n- **`<ListOverflowFooter>`** — \"+N more · View all\" footer for fixed-slice preview lists, with a\n  `note` slot to surface backend caps honestly.\n- **`<Stat>`** gains a `loading` skeleton variant and value-overflow handling (truncate + title) so\n  KPI tiles never reflow or overflow on large/long values.\n- **`<DataTable>`** gains `error` / `onRetry` / `errorState` props rendering a distinct error branch\n  (desktop + mobile card view), separate from loading and empty.\n\nAdoption across dashboard/module/admin/saas screens follows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:36.797Z","updatedAt":"2026-06-22T20:54:36.797Z"},{"id":"b9d91042-272b-4768-93d6-29ebdc11a8d9","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"ui-phase0-data-fixes","type":"fixed","scope":"web","summary":"Payroll money-format no-op arg and projects \"Teams\" tile showing a raw i18n key.","body":"UI-redesign Phase 0 (concrete data-correctness fixes from the audit):\n\n- Payroll overview `fmtMoney` passed `0` as `formatMoney`'s options argument (which expects an\n  object) — a no-op that read as a decimals setting. Now uses currency-aware defaults.\n- The Projects overview \"Teams\" stat tile rendered the literal i18n key `projects.team.list` as\n  its caption; replaced with a real translated caption.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:37.615Z","updatedAt":"2026-06-22T20:54:37.615Z"},{"id":"33102a84-993c-48df-be5a-d3cc45a2fa9b","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"ui-money-statusbadge","type":"added","scope":"web","summary":"Shared <Money> and <StatusBadge> data-display components.","body":"UI-redesign data-display vocabulary (apps/web):\n\n- **`<Money>`** — the canonical monetary render: currency-aware (via the shared `formatMoney`),\n  always `tabular-nums`, null/empty-safe, compact + full-value title tooltip. `currency` is a\n  REQUIRED prop, so the recurring hardcoded-'USD' bug class becomes structurally impossible.\n- **`<StatusBadge>`** — the canonical status chip: maps a status enum → tone + label via a\n  per-domain map and humanizes anything unmapped (so a raw `in_progress` enum or a leaked i18n key\n  never renders verbatim), rendering through the `Badge` primitive. Replaces per-screen\n  `function StatusBadge()` copies.\n\nPairs with the data-state primitives (QueryState/ErrorState/Num/ListOverflowFooter). Adoption sweep\nacross dashboards/modules/admin/saas follows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T20:54:37.623Z","updatedAt":"2026-06-22T20:54:37.623Z"},{"id":"c3204b6a-6531-4118-acc2-adffa28bb794","releaseId":"6ba673a9-9779-4f2c-90e2-dcd52b53e723","slug":"ui-adoption-saas-stats","type":"fixed","scope":"web","summary":"Platform stats page shows an error+retry state instead of a blank screen on load failure.","body":"UI-redesign adoption (Phase 1, first screen). The `/saas/stats` platform-stats page previously\nreturned `null` (a blank screen) when its query failed — \"load failed\" was indistinguishable\nfrom \"no data\". It now renders the shared `<ErrorState>` with a Retry action. The new data-state\nprimitives (`ErrorState`, `QueryState`, `Num`, `ListOverflowFooter`) are also re-exported from the\napp `components/primitives` barrel so the rest of the adoption sweep can use them consistently.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-22T21:54:34.192Z","updatedAt":"2026-06-22T21:54:34.192Z"}]},{"id":"d96f692a-7cf1-4990-8c35-e6532607bd38","tag":"W2026-23","slug":"w-w2026-23","version":null,"title":"W2026-23 — 319 changes this week","summary":"Auto-published weekly digest. Covers 319 changes from 2026-06-01 → 2026-06-04 merged into main.","status":"published","publishedAt":"2026-06-04T01:29:41.694Z","periodStartsAt":null,"periodEndsAt":"2026-06-04T01:29:41.694Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["auto","weekly"],"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:42.415Z","entries":[{"id":"07d2792f-57ad-4aa1-b444-fdb9a423b2ee","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-52-sidebar-unread-hover-popover-exit","type":"changed","scope":"chat","summary":"Round 52 — unread channel rows finally get a hover affordance (faint module-tinted inset ring via ::after pseudo) without breaking the unread tint; `[data-helios-chat-popover]` gains an opt-in `data-leaving=\"true\"` exit animation so popovers don't snap away on close.","body":"Surfaced by audit:sidebar Gaps #2 + #5.\n\n### 1. Unread channel-row hover affordance\n\nThe row's `style={{ boxShadow }}` attr (inline) sets the unread left strip / active ring / muted strip via a 4-way ternary. Tailwind's `hover:shadow-...` is overridden by the inline style — that's why round 43 added the muted strip via the ternary chain rather than a Tailwind class.\n\nSame constraint applies to unread+hover: a Tailwind `hover:` class can't paint a hover ring because the inline style wins. Solution: paint the hover ring on a `::after` pseudo-element. The pseudo stacks ABOVE the row's bg and shadow without touching the existing chain.\n\n```css\n.helios-chat-channel-row--unread-hoverable {\n  position: relative;\n}\n.helios-chat-channel-row--unread-hoverable::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  border-radius: inherit;\n  pointer-events: none;\n  box-shadow: inset 0 0 0 1px transparent;\n  transition: box-shadow 120ms cubic-bezier(0.16, 1, 0.3, 1);\n}\n.helios-chat-channel-row--unread-hoverable:hover::after {\n  box-shadow: inset 0 0 0 1px color-mix(in oklch, var(--color-module-chat) 40%, transparent);\n}\n```\n\nTagged via a Tailwind className on the row JSX:\n\n```diff\n- !isActive && unread === 0 && 'hover:bg-[var(--bg-hover)]',\n+ !isActive && unread === 0 && 'hover:bg-[var(--bg-hover)]',\n+ !isActive && unread > 0 && 'helios-chat-channel-row--unread-hoverable',\n```\n\nRead rows keep their existing `hover:bg-[var(--bg-hover)]`. Unread rows now get the faint inset ring on hover — telegraphs \"this is tappable\" without washing out the unread tint.\n\n### 2. Popover exit animation (opt-in via `data-leaving`)\n\nSidebar popovers (Saved, Pulse) have a 160 ms entrance via `.helios-chat-popover-in` (round 41) but snap away on close because there's no unmount animation. Symmetry-breaks the perceived quality.\n\nAdded:\n\n```css\n[data-helios-chat-popover][data-leaving=\"true\"] {\n  animation: helios-popover-out var(--duration-snap) var(--ease-standard) both;\n  transform-origin: top center;\n}\n@keyframes helios-popover-out {\n  to { opacity: 0; transform: translateY(6px) scale(0.96); }\n}\n```\n\nMirrors the entrance keyframe exactly — same duration, same easing, reversed transform. Consumers opt in by setting `data-leaving=\"true\"` for one animation duration before unmounting (Radix pattern: `data-state=\"closed\"`).\n\nWe intentionally did NOT retrofit every chat popover to use it — that's per-popover work involving state + setTimeout per consumer, and the JS plumbing across ~12 popovers is its own round. The CSS is in place so the consumer-by-consumer migration can land incrementally without re-shipping the keyframe.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** audit:sidebar Gaps #2 + #5 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.391Z","updatedAt":"2026-06-05T01:29:11.391Z"},{"id":"4996217e-911e-465e-8f17-cdeab0a8d982","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-twelve-new-blocks","type":"added","scope":"website","summary":"Phase 17.C — 12 new section block types (image / video / embed / accordion / tabs / columns / code / callout / gallery / timeline / stat_grid / pull_quote).","body":"The bespoke 14-block set covered marketing-page essentials but\nleft a wide gap vs operator expectations from generic CMS\nproducts: no image, no video, no embed, no accordion / tabs /\ncolumns layout primitives, no code snippet, no callout, no\ngallery / timeline / stat-grid / pull-quote. Operators reaching\nfor any of these had to fall back to `prose` markdown — fine for\na paragraph, awful for an embedded YouTube video or a 3-column\nstat grid.\n\nThis commit adds 12 typed blocks end-to-end:\n\n| Block | Use case |\n|---|---|\n| `image` | Single image + caption + align (left/center/right) + maxWidthPx |\n| `video` | Self-hosted MP4 OR YouTube / Vimeo / Loom embed; poster; aspect ratio |\n| `embed` | Generic third-party iframe (Calendly / Typeform / CodePen) |\n| `accordion` | Collapsible items + defaultOpenIndex + allowMultiple |\n| `tabs` | Horizontal tabs with hash-routed panels (zero-JS) |\n| `columns` | 2/3/4-col rich content row with markdown bodies |\n| `code` | Syntax-coloured snippet (Prism-class) + copy button |\n| `callout` | Info / success / warning / danger / neutral box with title + markdown body |\n| `gallery` | Image grid (2-5 cols) + optional lightbox |\n| `timeline` | Vertical milestone timeline with dates |\n| `stat_grid` | Grid of headline numbers (vs single `stat`) |\n| `pull_quote` | Large centered quote without attribution required |\n\nEnd-to-end means:\n\n- **Schema** (`modules/website/src/schemas/sections.ts`): per-\n  block Zod object added to the discriminated union; cap'd\n  field sizes (e.g. `image.alt` max 240, `code.code` max 20KB).\n  `SectionTypeSchema` enum updated; `BlockTypeSchema` in\n  `settings.ts` mirrored (allowedBlockTypes whitelist now\n  recognises the new types). Also closed pre-existing gap:\n  `global_ref` + `custom` were missing from `BlockTypeSchema`\n  too — added.\n- **Renderer** (`apps/marketing/src/components/cms/website-\n  page-renderer.tsx`): one switch case per new type. Inline\n  React components for the simpler shapes (`image` / `embed` /\n  `gallery` / `stat_grid` / `pull_quote`); markdown body\n  blocks (`callout` / `accordion` / `tabs` / `columns` /\n  `timeline`) pipe through the existing `markdownToHtml`\n  helper. Zero new dependencies — tabs use CSS `:target` for\n  panel switching; gallery uses native `<img loading=\"lazy\">`.\n- **Editor** (`apps/web/src/components/website/sections-\n  editor.tsx`): `BlockType` union, `ALL_BLOCK_TYPES` array,\n  `BLOCK_LABELS`, `BLOCK_META` (group + description + keywords\n  for slash-menu fuzzy match), and `defaultSection(type)`\n  sensible-default factories all extended for every new type.\n  Slash-menu palette + allowedBlockTypes whitelist + \"Add\n  section\" picker all pick the new blocks up automatically.\n\nPre-existing typecheck fix: `(base as { heading: string })`\ncasts in `transformSection()` were broken by the wider union;\nre-cast through `unknown` first per noUncheckedIndexedAccess.\nPlus `keywords: string[]` was being assigned to a CommandItem\nwhere `keywords?: string` — joined the arrays to strings.\n\n283 / 18 website tests still green. No schema migration needed\n— sections are JSONB.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-website-twelve-new-blocks.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2aa7a2ad-333b-4305-847d-c08b1dd2966b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-11-popovers","type":"changed","scope":"chat","summary":"Five channel popovers (Pinned / Saved / Threads / Members / Files) align to the V2 glass + token language.","body":"Round 11 polish — five channel-header popovers that the top button\nband opens dozens of times a day. They were the last surfaces still\non V1 styling (12 px literals, V1 chrome, no entrance animation,\nno V2 glass) and visibly mismatched the round-7-through-10 work.\n\n**Shared chrome upgrades (all five):**\n\n- Container chassis swaps to the V2 glass: 92% bg + 14 px blur +\n  150% saturation; 6%-foreground color-mix border; layered shadow\n  (48 px ambient + 10 px contact + 1 px ring) — matches the\n  channel + message context menus.\n- `helios-chat-popover-in` entrance animation added (160 ms cubic-\n  bezier rise + 96 → 100% scale). Threads + Members popovers had\n  no entrance at all; they pop in cleanly now.\n- `data-helios-chat-popover` scope so the V2 typography token\n  resolutions apply.\n- Header padding `px-3 py-2 → px-3.5 py-3` with a 5%-fg hairline\n  divider instead of the V1 border-subtle.\n- Header title promoted to `--text-chat-section` (14 px) semibold\n  tracking-tight (was 12 px).\n- Header icon promoted from a 20 px square medallion to a 24 px\n  rounded-full medallion with an inset tint ring (matches every\n  other V2 surface medallion).\n- Count badge moved to a 10.5 px tabular-num pill, only renders\n  when count > 0 (was always-on).\n- Close button bumps 20 → 24 px to a `rounded-full` with hover\n  state that brightens the icon to fg-default.\n\n**Per-popover specifics:**\n\n- **Pinned:** empty state medallion 36 → 44 px with inset ring;\n  empty title on `--text-chat-body`; hint on `--text-chat-meta`;\n  row author on `--text-chat-label` tracking-tight; pin-strip\n  hover indicator 2 → 2.5 px.\n- **Saved:** same empty-state language as Pinned; channel chip in\n  rows promoted to semibold tracking-tight with 10 → 12 px Hash\n  icon; author on `--text-chat-label`; body preview on\n  `--text-chat-body` with 1.45 line-height (was 12.5 px / leading-\n  snug).\n- **Threads:** ADDED a proper empty state (was a one-line\n  paragraph) — module-chat tinted medallion + headline +\n  helper; header gains a module-chat 5% gradient wash (matches\n  Pinned/Saved gradient); reply-count badge promoted to a\n  bordered pill with semibold tabular-nums; row body preview on\n  `--text-chat-body`.\n- **Members:** Add button + Cancel toggle promoted to a\n  `rounded-full` 12 px semibold pill; filter input gets a\n  rounded-full pill chassis on `--text-chat-meta`; member-row\n  avatar bumps `xs → sm` (24 → 28 px) so faces are recognisable;\n  name on `--text-chat-label` tracking-tight; email on\n  `--text-chat-meta`; admin badge upgraded to a module-chat-\n  tinted rounded-full uppercase 9.5 px / 0.06em caption (was a\n  flat neutral pill).\n- **Channel Files:** header count tinted pill matches the rest;\n  filter chips on `--text-chat-meta` with 0.06em-fg color-mix\n  borders (was the V1 border-subtle); file row medallion 32 → 36\n  px with `weight=\"duotone\"`; filename on `--text-chat-label`\n  tracking-tight; metadata line on `--text-chat-meta`; hover\n  buttons 28 → 32 px `rounded-full` with hover brightening; icons\n  12 → 14 px.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-11 file errors.\n\nRemaining popover work: scheduled / followups / pulse / channel-\nengagement / channel-decisions — same shared-chrome pattern would\nland them. They're lower-leverage (less-frequent surfaces) so\nthey can come in a smaller follow-up commit if needed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-11-popovers.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2972cf90-6b9e-4edd-b880-5c89b3dc11fa","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-12-remaining-popovers","type":"changed","scope":"chat","summary":"Remaining five channel popovers (Scheduled / Engagement / Decisions / Followups / Pulse) land on V2 glass + tokens.","body":"Round 12 closes the popover polish queue. Round 11 landed the\nfive Slack-style discovery popovers (Pinned / Saved / Threads /\nMembers / Files); round 12 handles the remaining five — Pulse,\nFollowups, Scheduled, Channel Engagement, Channel Decisions —\nplus Scheduled's empty state which was a one-line paragraph.\n\nWhat changed:\n\n**Shared chrome upgrades (all five):**\n\n- Container chassis swaps to the V2 glass: 92% bg + 14 px blur +\n  150% saturation; 6%-foreground color-mix border; layered shadow\n  (48 px ambient + 10 px contact + 1 px ring).\n- `data-helios-chat-popover` scope so V2 typography tokens\n  resolve.\n- Header padding `px-3 py-2.5 → px-3.5 py-3` with a 5%-fg\n  hairline divider instead of V1 border-subtle.\n- Header title promoted to `--text-chat-section` (14 px)\n  semibold tracking-tight (was 12 px).\n- Header icon promoted from a 20 px square medallion to a 24 px\n  rounded-full medallion with an inset tint ring — matches the\n  round-7-through-11 V2 medallion language.\n- Count badges → 10.5 px tabular-num pills, render only when\n  count > 0.\n- Close button bumps 20 → 24 px to a `rounded-full` with hover\n  brightening to fg-default.\n\n**Per-popover specifics:**\n\n- **Scheduled:** ADDED a proper empty state (was one paragraph)\n  — module-chat 12% medallion + helper; row channel chip\n  promoted to semibold tracking-tight on `--text-chat-meta`;\n  body preview on `--text-chat-body` with 1.45 line-height;\n  Cancel button → `rounded-full` 11 px semibold pill (was\n  rounded text); future-time chip on 11 px mono.\n\n- **Channel Engagement:** \"Last 14 days\" affordance promoted to\n  a `--text-chat-caption` (11 px) 0.06em uppercase caption (was\n  a tiny inline 10.5 px); same medallion + chrome upgrades as\n  the rest.\n\n- **Channel Decisions:** Sparkle badge in the header picks up\n  the `helios-chat-ai-sparkle` ambient breath so the AI surface\n  visibly differs; rescan + close buttons → 24 px rounded-full\n  with hover brightening; loading-state Sparkle bumped 11 → 12\n  px with breath animation.\n\n- **Followups:** Due-count chip promoted to a 9.5 px 0.06em\n  uppercase pill (was 10 px bold) — matches the round-7 metadata\n  language for transient alerts; icon medallions gain inset tint\n  rings; header items 1.5 → 2 gap.\n\n- **Pulse:** Sparkle icon moved into a proper module-chat\n  medallion (was floating 14 px standalone) with `helios-chat-ai-\n  sparkle` ambient breath; unread + mentions metadata line on\n  `--text-chat-meta` with tabular nums; \"@N mentions\" chip → 10.5\n  px tabular semibold with warning glow shadow; empty state\n  medallion 40 → 44 px with inset ring + headline on `--text-\n  chat-body`; footer hint on `--text-chat-meta` (was 10.5 px);\n  \"Catch up everything\" CTA → `rounded-full` 12 px semibold with\n  hover lift + 18 px shadow glow (was a flat rect chip).\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-12 file errors.\n\nThis closes the chat-popover queue. Every popover triggered from\nthe sidebar top button band, the channel header, or the channel\ndetail flyouts now matches the V2 glass + token language.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-12-remaining-popovers.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a6f58ab2-b370-44ae-98b9-11eded271be2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-13-main-surface-2026-trends","type":"changed","scope":"chat","summary":"Main chat surface (dividers + composer) lands on 2026 chat-UI trends — clarity over decoration, generous line-height, V2 chrome.","body":"Round 13 — the **main chat reading surface** finally meets the\nround-7-through-12 V2 polish on the satellite surfaces. Earlier\nrounds drained the sidebar, message-metadata, popovers, and\ncontext menus. Round 13 pivots to what the user actually stares\nat all day: day dividers, unread markers, and the composer.\n\n**Why now (2026 UI research):**\n\nCross-referenced UXPin's chat-UI study, Tubik's \"7 UI design\ntrends of 2026\", and the BricxLabs \"16 chat UI patterns that\nwork in 2026\" survey. Three findings drove the round:\n\n1. **Clarity over decoration.** Linear's reimagined Liquid Glass\n   tab bar is \"tuned for clarity over decoration\"; the 2026\n   \"anti-Liquid Glass\" movement dismantles glass on primary\n   reading surfaces (it stays valid on overlays — popovers,\n   menus). The previous DateDivider used a 6 px backdrop blur\n   + glass pill + multi-stop gradient + box-shadow on a\n   message-stream divider — pure decoration that did no\n   information work.\n2. **Generous line-height (~1.6) for sustained reading.** WCAG\n   2.2 + UXPin chat research agree: 1.5–1.6 is right; under 1.5\n   strains the eye. Our composer was at 1.55; bumped to 1.6.\n3. **Subtle muted dividers grouped by date.** Day separators\n   should be unobtrusive uppercase captions, not floating\n   chrome. Slack/Discord both went this direction in their\n   2024-2026 rebuilds.\n\nWhat changed:\n\n**DateDivider (message-list `<DateDivider>`)**\n\n- Dropped the 6 px backdrop blur + glass pill + gradient rail +\n  drop-shadow. Replaced with a single 5%-fg hairline + an\n  uppercase 0.08em-tracking caption pill on bg-default.\n- Migrated to `--text-chat-caption` (11 px) tokens. Padding\n  `px-3 py-1 → px-3 py-[3px]` with 0.08em tracking.\n- Margin `my-3 → my-[var(--space-chat-divider,16px)]` so the\n  divider rhythm scales with the V2 density toggle.\n\n**UnreadDivider**\n\n- Kept the high-salience glow (unread markers are still the\n  \"jump-the-gun\" affordance — 2026 chat patterns retain a\n  strong unread divider while subduing day separators).\n- Migrated label to `--text-chat-caption` with 0.10em tracking\n  + font-weight 600 → 700.\n- Dot bumped 4 → 5 px so it reads at glance from the rail.\n- Padding `px-2.5 py-0.5 → px-3 py-[3px]`; gap 1 → 1.5.\n- Margin same density-aware bump as DateDivider.\n\n**Composer (`composer-tiptap.tsx`):**\n\n- Outer wrapper border darkens 10%-fg color-mix (was the\n  legacy `border-default`) + adds a 1 px ambient shadow so the\n  composer reads as a distinct interactive surface against the\n  channel column — addresses the 2026 \"clear border on input\n  surfaces\" pattern.\n- Editor content area padding `px-3 pt-2.5 → px-3.5 pt-3` /\n  `px-2 pt-1.5 → px-2.5 pt-2` (compact) for a calmer reading\n  area; line-height 1.55 → 1.6 per the WCAG/UXPin study.\n- Toolbar border-top swaps to 5%-fg color-mix; padding\n  `pb-1.5 pt-1 → pb-1.5 pt-1.5`.\n- ToolbarSep migrates from `var(--border-faint)` to 8%-fg\n  color-mix (consistent with the V2 separator language across\n  rounds 7-12).\n- Cancel button → `rounded-full px-2.5 py-1` semibold tracking-\n  tight on `--text-chat-meta` (was `rounded px-2 py-1 text-[12px]`).\n\n**Composer attachment chip rail:**\n\n- Container rail: bg `var(--bg-emphasis)` → 3%-fg color-mix +\n  border-subtle → 6%-fg color-mix; `mb-1.5 px-2 py-2` →\n  `mb-2 px-2.5 py-2`. Reads as a designed tray, not a flat\n  emphasis block.\n- Non-image attachment chip: padding `px-2.5 py-1.5` →\n  `px-2.5 py-2`; max-width 220 → 240; gap 2 → 2.5; hover\n  border picks up tint awareness (28%-tint color-mix).\n- Filename promoted to `--text-chat-meta` semibold tracking-\n  tight (was 11.5 px medium).\n- Icon medallion 24 → 28 px with an inset 1 px tint ring\n  (matches round-8 attachment-list FileChip polish).\n- Remove button 20 → 24 px `rounded-full` with hover\n  brightening (was rounded-md with no hover-color shift).\n\n**Drag-over banner:**\n\n- Font promoted to `--text-chat-body` (15 px) semibold\n  tracking-tight — was a 12 px medium. The \"Drop files to\n  attach\" affordance now reads as a primary instruction, not a\n  faint hint.\n\n**Upload error chip:**\n\n- Migrated from a 11 px standalone paragraph to a designed\n  `rounded-[var(--radius-sm)]` tinted chip with 8%-danger bg\n  + medium tracking-tight on `--text-chat-meta`. Reads as a\n  designed state, not a regular sentence in danger color.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-13 file errors.\n\nResearch sources:\n- [UXPin — Chat UI Design 2026](https://www.uxpin.com/studio/blog/chat-user-interface-design/)\n- [Tubik — 7 UI Design Trends of 2026](https://blog.tubikstudio.com/ui-design-trends-2026/)\n- [BricxLabs — 16 Chat UI Patterns 2026](https://bricxlabs.com/blogs/message-screen-ui-deisgn)\n- [CometChat — Chat App Design Best Practices](https://www.cometchat.com/blog/chat-app-design-best-practices)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-13-main-surface-2026-trends.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"15922851-3ee7-40e6-8b8e-7396daf87064","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-14-empty-and-caught-up-states","type":"changed","scope":"chat","summary":"Empty channel state, caught-up inbox welcome, and inline AI-thinking indicator land on the 2026 trend system.","body":"Round 14 — closes three main-surface states that round 13 didn't\ntouch: the channel-view empty body, the /chat catch-up welcome\nscreen, and the inline AI-composing indicator above the\ncomposer. These are the surfaces a user lands on every morning\nbefore any conversation is open.\n\n**What changed:**\n\n**Message-list empty channel state:**\n\n- Promoted from a single 14 px medium paragraph to a proper\n  two-line empty state: headline \"Start the conversation\" on\n  `--text-chat-title` (17 px) semibold tracking-tight + a\n  hint line on `--text-chat-meta` with 1.5 line-height.\n- Medallion 48 → 56 px with an inset 1 px module-chat ring +\n  8 px outer halo so it reads as a designed surface, not a\n  floating emoji.\n- Hint line max-width widened 28 → 32 ch per the 2026 reading\n  research (65-72 chars total when adjusted for icon centering).\n\n**`/chat` catch-up state (you're all caught up):**\n\n- Headline scale bumped `+2px → +4px` of `--text-chat-display`\n  with `-0.015em` letter-spacing — the surface is genuinely\n  celebratory; the heading should feel like the moment of\n  payoff.\n- Medallion 56 → 64 px, rounded-2xl → rounded-full, with inset\n  1 px tint ring + 8 px tinted outer halo (matches the round-7\n  medallion language).\n- Body copy line-height set to 1.55 and max-width 42ch per\n  WCAG 2.2 reading guidance.\n- Stat row border softened from `border-default` to 8%-fg\n  color-mix with a 1 px ambient shadow (anti-Liquid Glass /\n  designed-card feel per round 13).\n- Stat values bumped 20 → 24 px tracking-tight; labels migrated\n  to `--text-chat-caption` (11 px) 0.10em uppercase semibold.\n- ShortcutPill: padding `px-2.5 py-1` → `px-3 py-1.5`; gap\n  1.5 → 2; hover border picks up module-chat 24%-tint awareness;\n  hover shadow 10 → 18 px ambient lift; label semibold tracking-\n  tight; kbd 9.5 → 10 px mono.\n\n**Inline AI-thinking indicator (channel-view above composer):**\n\n- Promoted from a flat 11 px paragraph to a designed pill\n  matching the round-9 quick-reply-chips \"Suggested\" header\n  language: 10% AI-tint bg + 22% AI-tint border + AI-600 text\n  + rounded-full `px-2.5 py-[3px]`.\n- Label semibold tracking-tight on 11.5 px so the\n  \"{appName} AI is composing…\" reads as a single typographic\n  unit, not a paragraph.\n- Dots get 3 px gap (was 2 px) + 1 px top padding so they sit\n  on the optical baseline of the text.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-14 file errors.\n\nTogether with round 13's divider + composer work, the central\nchat reading surface now matches the V2 polish on every\nsatellite — there is no V1-styled spot a user will land on by\ndefault in normal use.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-14-empty-and-caught-up-states.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9635b3cb-c6d1-4406-8cc2-bab88019c242","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-15-side-panes-and-banner","type":"changed","scope":"chat","summary":"Thread pane, Ask AI pane, and notification permission banner align to the V2 token system.","body":"Round 15 — three more side-of-the-main-surface affordances that\nopened on every typical chat session: the **Thread pane** (every\nreply-in-thread), the **Ask AI pane** (every \"Ask AI\" click),\nand the **notification permission banner** (every new user's\nfirst session with unreads).\n\n**Thread pane:**\n\n- Header height bumped 48 → 64 px to match the channel header's\n  `--space-chat-header` rhythm — the two columns now align\n  visually instead of being staggered.\n- Header gains a glass treatment (92% bg + 12 px blur + 140%\n  sat) matching the channel header.\n- Title block restructured: added an `iconmedallion` (24 px\n  rounded-full with module-chat 14% bg + inset tint ring +\n  ChatTeardrop). Title promoted to `--text-chat-title` (17 px)\n  tracking-tight; reply count moved to a second line on\n  `--text-chat-meta`.\n- Pane width 400 → 420 px (matches Ask AI pane + the 2026\n  research's 65-72 char reading guidance).\n- Resolve/Reopen button: V1 `rounded-[var(--radius-sm)]\n  px-2 py-0.5 text-[11px]` → rounded-full `px-2.5 py-1` on\n  `--text-chat-meta` semibold tracking-tight; success-tinted\n  border softens.\n- Close button 24 → 28 px rounded-full with hover brightening.\n- \"Thread is resolved\" banner: label promoted to\n  `--text-chat-meta` semibold tracking-tight; Reopen action\n  becomes a rounded-full 10 px 0.06em uppercase pill.\n- Tree connectors (vertical + horizontal twigs) migrated from\n  `var(--border-faint)` to 6%-fg color-mix to match the V2\n  separator language.\n- Empty replies state: V1 single inline icon + paragraph\n  → proper centered empty state with module-chat medallion +\n  inset ring + headline \"No replies yet\" on `--text-chat-label`\n  + hint on `--text-chat-meta` with 1.45 line-height.\n- Root-message separator switches to 5%-fg color-mix.\n\n**Ask AI pane:**\n\n- Header height 48 → 64 px matching `--space-chat-header`.\n- Header background goes from a flat AI-tinted block to a\n  vertical gradient (9% top → 4% bottom) so the surface\n  visibly fades into the conversation area.\n- Title icon medallion 24 → 28 px rounded-full with inset 1 px\n  AI-tint ring (matches the V2 medallion language).\n- Title promoted to `--text-chat-title` (17 px) tracking-tight;\n  channel-label sub-line on `--text-chat-caption` (11 px) with\n  0.08em uppercase tracking.\n- Close button 24 → 28 px rounded-full with hover brightening.\n- Empty state: medallion 40 → 48 px with inset 1 px tint ring\n  + 8 px outer halo (matches round-14 medallion language);\n  title on `--text-chat-title`; body on `--text-chat-meta`\n  with 1.55 line-height and 36ch max-width; container border\n  softens (24 → 22% AI-tint) + adds a 1 px ambient shadow.\n- Empty-state preset prompt chips bumped: `px-2.5 py-1\n  text-[11.5px]` → `px-3 py-1.5` on `--text-chat-meta`\n  semibold tracking-tight; hover border darkens to 50% AI-tint\n  with a 28% AI-tint glow shadow (was a generic 10 px shadow).\n- Open-failed error: flat 12 px paragraph → designed tinted\n  chip (8%-danger bg + rounded `--radius-sm`).\n\n**Notification permission banner:**\n\n- Padding `px-2.5 py-2 → px-3 py-2.5`; gap 2.5 → 3.\n- Border tint softens 38% → 32% so it doesn't overpower the\n  sidebar.\n- Shadow upgrade: 18 → 22 px ambient + 1 px contact (designed-\n  card feel per round 13).\n- Icon medallion 24 → 28 px with inset 1 px tint ring.\n- Headline migrated to `--text-chat-label` (14 px) semibold\n  tracking-tight (was a default-size paragraph with leading-\n  tight). Body on `--text-chat-meta` with 1.45 line-height.\n- \"Enable notifications\" CTA: V1 `h-6 rounded-md text-[11px]`\n  → `h-7 rounded-full px-3` on `--text-chat-meta` semibold\n  tracking-tight; shadow upgraded from 10 → 12 px module-chat\n  glow.\n- Close button 20 → 24 px rounded-full with hover brightening.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-15 file errors.\n\nTogether with the previous 8 rounds, **every** chat surface the\nuser lands on by default — channels, dividers, messages,\nmetadata, popovers, context menus, thread panes, AI panes,\nempty states, catch-up screen, notification banner — matches\nthe V2 token + glass language.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-15-side-panes-and-banner.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b294bbdc-66bd-4773-b8f8-d1d02e3df1c9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-19-pickers-and-search","type":"changed","scope":"chat","summary":"Emoji picker, @-mention popover, and search modal land on V2 glass + tokens.","body":"Round 19 polishes three high-frequency surfaces that opened in\nevery typical session: the emoji picker (every reaction +\ncomposer emoji), the @-mention autocomplete popover (every\ntagged teammate), and the chat-wide search modal.\n\n**Emoji picker:**\n\n- Container chassis swaps to V2 glass: 92% bg + 14 px blur +\n  150% saturation; 6%-fg color-mix border; layered shadow.\n- `data-helios-chat-popover` scope + `helios-chat-popover-in`\n  entrance animation.\n- Width 280 → 304 px (more comfortable 8-column grid).\n- Search input: V1 flat `rounded` bg-emphasis → rounded-full pill\n  with bordered chassis on `--text-chat-meta`, focus ring 2.\n- Section headers (Frequent / Smileys / Animals…): 10 px medium\n  → `--text-chat-caption` (11 px) 0.08em semibold uppercase.\n- Emoji buttons: `rounded` → `rounded-full`, glyph 16 → 18 px,\n  hover scales to 1.20 with active-press 0.92 + V2 transition\n  curve (was no transition at all).\n\n**@-mention popover:**\n\n- Same V2 glass + entrance + popover scope as the emoji picker.\n- Width 300 → 320 px.\n- Header bar: 10 px / 0.08em uppercase → `--text-chat-caption`\n  (11 px); kbd hints get a proper bordered chassis with\n  `font-mono` tabular nums.\n- Rows: 13 px → `--text-chat-row` (14 px); padding `px-2.5 py-1.5`\n  → `px-3 py-1.5`.\n- User row: avatar `xs → sm` (24 → 28 px); name on\n  `--text-chat-label` semibold tracking-tight; email on\n  `--text-chat-meta`.\n- Entity row: medallion 24 → 28 px rounded-sm with inset 1 px\n  tint ring; icon 12 → 14 px; name on `--text-chat-label`\n  tracking-tight; type label semibold with `-0.005em` letter-\n  spacing.\n- Active-row left strip 2 → 2.5 px.\n- \"Linked work\" divider: 9 → 9.5 px caption with V2 color-mix\n  rail.\n\n**Search modal:**\n\n- Modal body gets `data-helios-chat-popover` so the V2 typography\n  tokens cascade.\n- Search input: 14 px → `--text-chat-body` (15 px); leading icon\n  16 → 18 px.\n- Searching indicator: flat 11 px paragraph → designed rounded-\n  full pill with module-chat tint + a small pulsing dot (matches\n  the V2 \"active state\" pill language).\n- Filter chip row: padding `py-2 → py-2.5`; \"Filters\" label →\n  `--text-chat-caption` 0.08em semibold; syntax hint moves to\n  `font-mono` 10.5 px.\n- Empty state: medallion 40 → 48 px with inset 1 px tint ring +\n  8 px tinted halo; title `12.5 px` → `--text-chat-title`\n  (17 px); body `11.5 px` → `--text-chat-meta` with 1.5 line-\n  height and 360 ch max-width per 2026 reading research.\n- No-results state: same medallion + title language as empty\n  state; matching 12 → 15 px hierarchy.\n- \"N results\" label: 10.5 px → `--text-chat-caption` 0.08em\n  semibold with tabular nums.\n- Hit row: channel medallion `rounded-[3px]` → `rounded-full`\n  with inset tint ring, 16 → 20 px, icon 9 → 10 px; meta line on\n  `--text-chat-meta` with V2 color-mix dot separators; body on\n  `--text-chat-body` (15 px) tracking-tight + 1.45 line-height.\n- Active-row left strip 3 → 2.5 px (consistent with sidebar +\n  popovers).\n- Keyboard hints footer migrates to `--text-chat-meta` with V2\n  color-mix top border; kbd glyph 9.5 → 10 px on a V2 6%-fg-\n  color-mix-bordered chassis.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-19 file errors.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-19-pickers-and-search.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"993a6d99-bc81-4f88-b9e8-e1e387e72fab","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-21-dialogs-hover-card-embed","type":"changed","scope":"chat","summary":"Confirm/prompt dialogs, user hover-card, and card-embed surfaces on V2 tokens — every destructive action + every avatar hover + every cross-module card.","body":"Round 21 — three high-frequency surfaces that previous rounds\nskipped. From the round-20 status report: ConfirmDialog appears\non **every destructive action** (delete message / leave channel /\nkick member); UserHoverCard appears on **every avatar hover** in\nthe message list, sidebar DMs, members popover, and thread pane;\nCardEmbed renders **every cross-module card message** (Linear\nissue, support ticket, sales invoice, etc.).\n\n**ConfirmDialog (`dialogs.tsx`):**\n\n- Body wrapped in `data-helios-chat-popover` so V2 typography\n  tokens cascade.\n- Message text 13 px → `--text-chat-body` (15 px) with 1.55\n  line-height per the 2026 reading-research findings (round 13).\n- Action button row spacing `mt-4 → mt-5` for clearer separation\n  from the body copy.\n- Destructive button shadow tightened: 12 → 14 px ambient at 55%\n  opacity (was 60%) for a less-aggressive but still-clearly-\n  destructive feel.\n\n**PromptDialog (same file):**\n\n- Label promoted from `text-[12px] font-medium fg-muted` to\n  `--text-chat-meta` semibold tracking-tight fg-default.\n- Input + textarea: `rounded-sm border-default text-[13px]` →\n  `rounded-md` with 10%-fg color-mix border + `--text-chat-body`\n  (15 px); textarea gains 1.55 line-height; padding bumped to\n  `px-3 py-2`.\n- Action row padding `pt-1 → pt-2` for visual breathing room.\n\n**UserHoverCard:**\n\n- Width 280 → 300 px (better fits the 15 px name + email line).\n- Glass chassis swaps to V2 standard: 92% bg + 14 px blur + 150%\n  saturation, 6%-fg color-mix border, layered shadow (48 px\n  ambient + 10 px contact + 1 px ring).\n- Body padding `gap-2.5 p-3` → `gap-3 p-3.5`.\n- Name promoted to `--text-chat-label` (14 px) semibold\n  tracking-tight.\n- Presence line on `--text-chat-meta`; status dot 6 → 6 px\n  (kept) but `in_huddle` recolours from `--color-module-chat` to\n  `var(--accent)` so it stays on-brand even when the operator\n  changes accent (consistent with round 20's accent inheritance).\n- Email line goes from 11.5 → `--text-chat-meta`; envelope icon\n  11 → 12 px.\n- Footer button: 12.5 px → `--text-chat-row` (14 px) tracking-\n  tight; medallion `rounded-[3px] size-5` → `rounded-full size-6`\n  with inset 1 px tint ring (matches round-11's popover medallion\n  language); label semibold; padding `px-2 py-1.5` → `px-2.5 py-2`.\n\n**CardEmbed:**\n\n- Container padding `px-3.5 py-3` → `px-4 py-3.5`.\n- Title `text-[14px]` → `--text-chat-label` semibold tracking-\n  tight + 1.3 line-height; body `text-[13px] leading-relaxed` →\n  `--text-chat-body` (15 px) with 1.55 line-height — matches the\n  V2 message-body scale so an embedded card reads at the same\n  rhythm as the surrounding messages.\n- Action button row: gap `1.5 → 2`; buttons go `rounded-md\n  text-[12px] px-3 py-1.5` → `rounded-full px-3.5 py-1.5` on\n  `--text-chat-meta` semibold tracking-tight (matches round-8's\n  attachment-chip + round-11's popover button language).\n- Default button border: V1 `border-default` → 10%-fg color-mix.\n- Primary + danger drop-shadow ambient softened 12 → 12 px at\n  55% tint (was 60%).\n- Error message: standalone `10.5 px` → `--text-chat-meta`\n  medium tracking-tight on `--color-danger-600` (no more visual\n  cliff vs body copy).\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-21 file errors.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-21-dialogs-hover-card-embed.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"344d9475-2874-4619-8e6c-9e62caf636e1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-respects-platform-accent-and-drag-regression-fix","type":"fixed","scope":"chat","summary":"Chat module inherits the platform's branded accent (no more fixed teal); fixes a drag-start regression where channels couldn't be dragged.","body":"Two fixes in one round — both reported by the user back-to-back,\nboth single-cause.\n\n**1. Chat now inherits the operator's branded accent.**\n\nThe chat module had its own fixed teal accent (`oklch(56% 0.11 165)`\nlight / `oklch(72% 0.12 165)` dark) injected via\n`--color-module-chat`. Every chat surface that read this var —\nsidebar active strip, hover toolbar tint, popover medallions,\nquick-reply chip background, AI sparkle halo, drop-zone borders,\nemoji-picker focus ring, message-row module accent strip, etc. —\noverrode the platform's branded accent.\n\nThis violated the \"no-static-branding\" rule and made operator-\nbranded deployments (amber Odexy, etc.) look wrong inside chat\nspecifically.\n\nFix: `--color-module-chat` now aliases to `var(--accent)` in both\nthe light and dark `:root` blocks of [packages/ui/src/globals.css](../../packages/ui/src/globals.css).\nBoth themes pull from the same `--color-accent-*` scale that\n`platform_settings.app_color_primary` injects, so per-theme\nlightness still works without a separate value.\n\nZero call-site changes — every existing `var(--color-module-chat)`\nreference re-skins automatically when the operator changes their\nbrand color in Settings → Branding.\n\n**2. Drag fix — channels weren't draggable anymore.**\n\nRound 16's drop-to-remove-from-category zone + Round 17's source-\nrow dim BOTH set React state synchronously inside `onDragStart`\n(`setDraggingChannelId`, `setIsDragging`). React 18 batches the\nupdates, but the commit still runs inside the same task as the\ndragstart event. The reconciliation moved / replaced the source\nDOM node before the browser had a chance to snap the drag ghost,\nand the browser aborted the drag. Result: the user could grab a\nrow but the drag never started.\n\nFix: both setters are now wrapped in `queueMicrotask(() => …)` so\nReact's reconcile runs AFTER the browser has captured the ghost.\nThe drag visual + the parent's \"drop to remove from category\"\nzone still appear, but one event-loop tick later instead of\nsynchronously. The visual delay is imperceptible (~1 ms); the\ndrag now starts reliably.\n\nSymmetric `queueMicrotask` wrap on `onDragEnd` too — defensive,\nsince dragend fires after drop, but consistency matters.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero touched-file errors.\n\nRound 16/17 still ship the drop zone + drag-handle grip; they\njust now correctly compose with the native drag lifecycle.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-respects-platform-accent-and-drag-regression-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"48402ba9-6ab3-481d-bdaa-71a486455315","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-34-long-press-context-menu","type":"added","scope":"chat","summary":"Round 34 — long-press a message on touch (~500 ms) to open the same context menu that right-click opens on desktop, with haptic feedback. iMessage / WhatsApp / Telegram pattern.","body":"The 2026 research consensus across Dribbble, iMessage, WhatsApp, Telegram, and Slack mobile: tap-and-hold is the universal mobile gesture for \"give me the full menu for this message.\" Helios's existing mobile gestures covered swipe-right-to-reply but the hover-actions toolbar + quick-react palette only fire on `:hover`, leaving touch users without a path to reactions / forward / pin / extract-tasks except via the overflow button (which itself is hover-revealed).\n\n**What lands:**\n\n- New `useLongPress` hook at [apps/web/src/components/chat/use-long-press.ts](../../apps/web/src/components/chat/use-long-press.ts):\n  - 500 ms hold duration — matches iOS Haptic Touch + Android long-press timing exactly. Anything shorter feels twitchy; longer feels broken.\n  - 10 px movement tolerance — any wander > 10 px in either axis cancels the press so it never conflicts with vertical scroll, swipe-to-reply, or pinch-zoom.\n  - Coarse-pointer only (`matchMedia('(pointer: coarse)')`) — desktop mouse users get right-click; we don't want \"hold the left mouse button\" fighting with double-clicks + text selection.\n  - Coordinates with `useSwipeToReply` via the new `isPanning` prop — the moment swipe takes over, the long-press timer aborts (no context menu mid-swipe).\n  - Soft haptic at fire (`navigator.vibrate(12)`) so the menu's arrival is announced kinaesthetically before the visual lands.\n  - Synthetic-click suppression — after a long-press fires, the next `click` event is captured + swallowed so the row doesn't ALSO trigger its normal click handler (e.g., opening an image lightbox).\n\n- Wired into `message-item.tsx` alongside the existing swipe handlers — both gestures share the same touch events; the row dispatches to both hooks in sequence and they negotiate via the movement-tolerance + `isPanning` flag.\n\n- Opens `MessageContextMenu` at the touch coordinates — the same menu right-click opens, with the same options: react, reply, edit (if own), delete (if own), pin, copy, forward, extract tasks, mark unread.\n\n**Verification:** chat 107/107 tests pass; message-item.tsx + use-long-press.ts typecheck clean.\n\n**Sources researched:**\n- Dribbble Chat App / Messaging tag — 3,500+ shots scanned; tap-and-hold pattern is universal\n- UXPilot \"12 Glassmorphism UI Features, Best Practices, and Examples\"\n- Primotech \"UI/UX Evolution 2026: Micro-Interactions & Motion\"\n- StackBlog \"How to create micro-interactions with react-spring\"","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-round-34-long-press-context-menu.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4796c2ad-01ed-49a9-a6c7-9ad7dae49df3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-40-sticky-day-fade-on-idle","type":"changed","scope":"chat","summary":"Round 40 — sticky day-pill at the top of the message list fades to 45% opacity after 1.2 s of scroll-idle (watermark state); snaps back to full opacity the moment scroll resumes.","body":"Direct lift from 2026 Dribbble Pattern 16 in the chat-UI research: the sticky day chip should recede when the user has stopped scrolling, so it doesn't compete with the message bodies they're now reading. Telegram Desktop + Slack both ship this pattern; the floating pill in `apps/web/src/components/chat/message-list.tsx` already existed in Helios but stayed at 100% opacity forever.\n\n**What lands:**\n\n- New `scrollIdle` state in `MessageList`, plus an `idleTimerRef` debouncer.\n- In `onScroll()`: each scroll event clears `scrollIdle` (wake) and restarts a 1.2 s timer that re-sets `scrollIdle = true` (back to watermark).\n- The sticky pill's inline style binds `opacity: scrollIdle ? 0.45 : 1` + a 280 ms cubic-bezier transition so the fade reads as intentional, not jittery.\n\n**Why 1.2 s + 0.45 opacity:**\n- 1.2 s matches the typical \"I've finished scrolling, I'm reading now\" pause in chat triage (research benchmark across Telegram Desktop, Slack, Google Messages 2026 redesign).\n- 0.45 keeps the date readable (still passes WCAG 1.4.11 non-text contrast on default backdrop) — too low and the chip becomes a phantom, too high and it doesn't visibly recede.\n\nThe `useState` round-trip is fine here because the only consumer is a single CSS `opacity` value (no re-render storm — only when the boolean actually flips). No memo / portal / requestAnimationFrame complexity needed.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:**\n- Dribbble research Pattern 16 (workflow `wf_14d2b01a-8de`)\n- [Bricxlabs 2026 chat-UI patterns roundup](https://bricxlabs.com/blogs/message-screen-ui-deisgn)\n- Telegram Desktop changelog 2026","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-round-40-sticky-day-fade-on-idle.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d4b70e37-31b9-4515-88d8-d426ea5bde01","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-sidebar-drag-affordances","type":"changed","scope":"chat","summary":"Sidebar channel rows show a drag-handle grip on hover and fade to 40% while being dragged.","body":"Two small but high-impact follow-ups to round 16's drop-to-remove-\nfrom-category zone — they close the discoverability + visual-track\ngaps in the sidebar's drag UX.\n\n**Drag-handle grip on hover:**\n\nSlack / Linear / Notion all show a small grip icon at the left\nedge of a row on hover to teach the \"this is draggable\"\naffordance. Our channel rows had nothing — users had to discover\ndraggability accidentally.\n\n- A 12 px `DotsSixVertical` grip now appears at the row's left\n  edge on hover (`opacity 0 → 100%` over 150 ms).\n- `pointer-events: none` so it never intercepts clicks — the\n  whole row remains the drag source as before.\n- Tinted at 25%-of-foreground so it sits at the same visual\n  weight as the muted-text rows and doesn't compete with the\n  channel name / icon medallion.\n- Rendered only when `draggable` is true (i.e. joined non-DM\n  channels) so it doesn't mislead about non-draggable rows.\n- Hidden on touch / `prefers-reduced-motion` indirectly via the\n  group-hover dependency (no hover state on touch = never\n  visible).\n\n**Source-row dim during drag:**\n\nThe dragged channel previously stayed at full opacity while\nin flight, which made the in-place visual feel \"stuck\" — the\nghost rendered by the browser moved with the cursor but the\nsource was indistinguishable from any other row.\n\n- `isDragging` flag local to `ChannelRowItem`, toggled on\n  `dragstart` (true) / `dragend` (false — clears regardless of\n  drop result).\n- Source row's opacity goes to **40%** while `isDragging`,\n  layered above the existing muted-row 55% so the muted-and-\n  dragged state still reads correctly.\n- No transition on the opacity — the fade snaps in / out, same\n  no-animation rule as the rest of the row binary state.\n\nTogether with round 16's drop-to-remove zone, the sidebar drag\nUX now matches the Slack/Linear/Mattermost pattern: visual cue\nthat things are draggable, visual feedback that something *is*\nbeing dragged, and an obvious place to drop it to remove from a\ncategory.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero sidebar-file errors.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-sidebar-drag-affordances.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b762e7a3-cea2-4550-a5ec-66330ba70946","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-1-cmd-k-palette","type":"added","scope":"chat","summary":"V3 Bet 1 — Cmd+K command palette opens from any chat surface and jumps to channels, runs common actions, opens Ask AI.","body":"V3 Bet 1 from [`docs/chat/UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §2.2 + §4. The V2 plan's parking-lot item. Slack, Linear, Notion, Cron, Raycast all converged on this pattern in 2025-2026 — typing `Cmd+K` is the **primary discoverability surface** for the app.\n\n**What lands:**\n\n- Press `⌘K` / `Ctrl+K` from any chat surface → centred glass palette opens with focus in the search input.\n- Single search ranks **channels + DMs + actions + AI prompts** by a substring-position score (start-of-string > anywhere; shorter labels win ties).\n- Up / Down / Enter / Esc keyboard navigation throughout — never need the mouse.\n- Built-in actions: **Open inbox**, **Search messages**, **New channel**, **Start a DM**, **Toggle inbox dense / comfy**.\n- AI section pinned at the top: **Ask AI…** opens the existing AI side pane.\n- Channels show with their icon (Hash / Lock / ChatCircle / Users) and a tinted medallion that matches the chat-module accent.\n- Smart shortcut filter: when the user is typing inside a textarea / contenteditable (composer, dialog input), `Cmd+K` still toggles the palette — but the palette's open-state isn't stolen mid-compose.\n- Per-action `dispatchEvent` pattern: the palette doesn't prop-drill — it dispatches `helios:chat:search-open`, `helios:chat:new-channel`, `helios:chat:new-dm`, `helios:chat:ai-open` window events. The sidebar / inbox / composer subscribe wherever they exist.\n\n**Visual:**\n\n- Same V2 glass language as the round-11/12 popovers: 92% bg + 18 px blur + 160% saturation, 6%-fg color-mix border, layered shadow (70 px ambient + 22 px contact + 1 px ring).\n- `helios-chat-popover-in` entrance animation (160 ms rise + 96 → 100% scale).\n- Centred at `12vh` from the top — standard cmdk position.\n- 600 px wide max; full-width on mobile with side gutters.\n\n**Implementation:**\n\n- New file `apps/web/src/components/chat/chat-command-palette.tsx` (~340 lines).\n- Mounted once at the sidebar root (the sidebar mounts on every chat surface, so the palette does too).\n- Listens for `keydown` on `document` with a Cmd+K test. Open state is local React state; portal-rendered to `document.body`.\n- No external `cmdk` library dependency — our own ranking is fast enough for ≤100 channels; revisit if workspace channel counts balloon.\n- Uses TanStack Query (`enabled: open`) to lazy-load the channel list only when the palette opens; the cache shares with the sidebar's channels query so subsequent opens are instant.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck clean; @helios/web typecheck zero touched-file errors.\n\nV3 progress: **3 / 8 bets shipped** (1, 2, 6). Next: Bet 4-lite (`@ai` mention pattern). Bets 3 / 5 / 7 / 8 need backend work and remain flagged in [`UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-1-cmd-k-palette.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1aecb503-76c0-4d83-a201-90202a84d793","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-disable-default-guard","type":"changed","scope":"payments","summary":"Disabling the default payment provider now asks for confirmation, since it stops new payments until another default is set.","body":"Disabling the provider currently marked as default removes the routing\nfallback, so new charges fail until another default is chosen. The\nEnable/Disable control now warns and asks the admin to confirm before\ndisabling a default provider, instead of silently breaking new payments.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-disable-default-guard.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2f4f81c5-2ebb-4ac2-82c6-8d84076caf1d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-2-inbox-dense-mode","type":"added","scope":"chat","summary":"V3 Bet 2 — `/chat` catch-up inbox gets a Comfy / Dense toggle so power users on tall monitors can fit ~2× the unread cards per scroll.","body":"V3 Bet 2 from [`docs/chat/UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §2.3. Slack's March-2026 redesign added a dense inbox mode for power users processing high notification volumes; this lands the same affordance for Helios.\n\n**What changed:**\n\n- New **Comfy / Dense toggle** in the `/chat` inbox header (rounded-full pill that flips between the two states), persisted to `localStorage` so the choice survives reload.\n- Dense mode:\n  - Section padding 6 px → tighter\n  - Row padding `py-3 / py-2.5` → `py-1.5`\n  - Avatar 32 → 24 px\n  - Body preview line (channel topic, mention message body) **hidden**\n  - Section gap `gap-6` → `gap-3`\n- Comfy mode keeps round-14's polished spacing exactly as it was.\n\nDifferent from the global Compact / Default / Cozy density toggle which applies across the whole chat surface — this is inbox-only. The two compose cleanly: a user can run global \"Cozy\" density across channels + DMs but flip the inbox to \"Dense\" for triage-mode catch-up sessions.\n\n**How it's wired:**\n\n- React state `dense: boolean` in the inbox route, initialized from `localStorage.getItem('helios.chat.inbox.dense')`.\n- Toggle button writes the value back to localStorage on change.\n- The inbox body wrapper gets `data-inbox-density=\"dense\"` (or `\"comfy\"`).\n- CSS rules in `styles.css` target `.helios-chat-inbox[data-inbox-density='dense']` + child `[data-inbox-row]`, `[data-inbox-row-preview]`, `[data-inbox-row-avatar]` markers on the row components.\n\nThis is a pure-CSS density mode — no row component re-renders when the toggle flips, just the wrapper's data attribute changes.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck clean; @helios/web typecheck zero round-19 file errors.\n\nV3 progress: 2 / 8 bets shipped (this + Bet 6 fluid typography). Bets 1 (Cmd+K) and 4-lite (`@ai` mention) next; bets 3 / 5 / 7 / 8 need backend work and are flagged in [`UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-2-inbox-dense-mode.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"203ad3ed-3168-438d-9525-1774928ac2bb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"credit-note-unapply-and-void-credit-reversal","type":"added","scope":"sales","summary":"Credit-note applications can be reversed, and voiding/writing-off an invoice now releases its applied credits.","body":"Closed the credit-note reversal gap — applying a credit was previously a one-way door, and voiding a partly-credited invoice silently stranded the credit.\n\n- **New `sales.credit_note.unapply`** — the exact inverse of `apply`. Reverse a single application by its id, or every application of a note to an invoice by the `(creditNoteId, invoiceId)` pair. It releases the credit back to its note (restoring the note's remaining balance and un-freezing a fully-applied note back to `issued`) and removes the credit's contribution from the invoice's paid balance, recomputing the invoice status. Refuses on a void note or a void/written-off invoice. `dangerous: true`.\n- **Voiding or writing off an invoice now reverses every applied credit note first**, returning each credit to its parent note instead of consuming it permanently against a dead invoice.\n- **Voiding/writing-off is now refused while the invoice still has recorded cash payments** (net of processed refunds) — the operator must refund the money first. This also tightens a prior gap where a partially-cash-paid invoice could be voided silently. Internal credits are always released automatically; only real money blocks the transition.\n\nAll of this runs atomically (one transaction, row-locked, idempotent on retry), so a reversal either fully completes or fully rolls back. Emits the new `sales.credit_note.unapplied` and `sales.invoice.written_off` events.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-credit-note-unapply-and-void-credit-reversal.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1edd086f-3b6a-4f90-8273-8b542b59d841","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"credit-note-unapply-ui","type":"added","scope":"sales","summary":"Credit-note detail page can now reverse an applied credit from the Applications list.","body":"Each row in a credit note's Applications list now has a **Reverse** action (hidden once the note is void). It opens a reason-gated confirm and calls `sales.credit_note.unapply`, releasing that credit back to the note and restoring the linked invoice's balance. Previously the reversal was only reachable programmatically / via the AI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-credit-note-unapply-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"12053489-c409-44c3-8b84-52cc6cfeaa36","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"distribute-credit-atomic-deadlock-fix","type":"fixed","scope":"sales","summary":"Bulk credit-note distribution is now atomic (no double-spend), emits per-application events, and a latent void/unapply deadlock vector is closed.","body":"`sales.credit_note.distribute_remaining` walked the client's open invoices in a bare-db loop and wrote the note's running total once at the end from a stale snapshot — a mid-loop crash committed application rows + decremented invoice balances while the note still showed full remaining, so the same credit could be distributed/applied **again (double-spend)**. It also emitted no events, so subscribers missed bulk-distributed credits.\n\nIt now runs entirely in one `db.transaction`: it locks every candidate invoice `FOR UPDATE` in a deterministic id-ASC order, then the note last (a global lock order that's deadlock-free against apply/unapply/void), re-reads balances under the locks, applies FIFO (oldest invoice first) capping each slice at the invoice + note balances, derives the note's new applied total from the slices actually inserted, and emits one `sales.credit_note.applied` per slice. A mid-distribution failure now rolls back everything.\n\nIt also closes a **latent deadlock** the design surfaced in already-shipped code: `voidInvoice`/`writeOffInvoice` (and pair-mode unapply) enumerate every credit application on an invoice and lock each parent note — now ordered by `creditNoteId` so all transactions in the credit subsystem acquire note locks in the same id-ASC order. Phase 1.2 of the hardening plan; covered by real-database integration tests (FIFO, never-over-applies, applied-adds-to-existing, scoping, per-slice events, and multi-note void reversal). The non-locking `recordPayment` lost-update remains a separate, pre-existing follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-distribute-credit-atomic-deadlock-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7ce444c6-52f6-464d-9188-01ef5901fcc9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"duplicate-convert-atomic","type":"fixed","scope":"sales","summary":"Duplicating an invoice or quotation and converting a quote to an invoice are now atomic and survive a concurrent number clash.","body":"`sales.invoice.duplicate`, `sales.quotation.duplicate`, and `sales.quotation.convert` each allocated a document number then wrote the cloned header, lines, and created event as separate statements on the bare connection. A failure mid-way could leave an orphan header with no lines or no event, and a number collision with a concurrent create surfaced as an unmapped database error. All three now commit the header + cloned lines + created event in one transaction (event via the transaction-bound outbox) and retry on a number collision, recomputing the next number on a clean rollback.\n\nCompletes the Phase 1.6/1.7 write-path hardening across every number-allocating create in the sales module. Covered by new real-database integration tests (clone gets the next sequential number, copies all lines, emits exactly one created event, lands as a draft).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-duplicate-convert-atomic.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e3b54412-59ff-4c9f-99d5-2580fe8e3082","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"i18n-missing-var-crash","type":"fixed","scope":"web","summary":"A translated string with an unsupplied variable no longer white-screens the page (e.g. the signup plan step's \"{n}-day free trial\").","body":"# Fix: missing translation variable crashed the page\n\nThe signup plan step (`/signup?step=plan`) crashed with *\"The intl string context variable 'n' was not provided to the string '{n}-day free trial'\"*. The code formatted the ICU string `{n}-day free trial` and then tried to `.replace('{n}', …)` — but the ICU formatter requires the variable at format time, so it threw before the replace ran.\n\n- **Signup**: the trial badge now passes `{ n: plan.trialDays }` to the formatter instead of a post-hoc `.replace`.\n- **Projects data panel**: two delete-confirmation warnings (`{count}` embeds / notes) had the same `.replace` antipattern and would have thrown when deleting a referenced artifact — both now pass `{ count }`.\n- **Resilience**: the i18n formatter (`Translator.t` + `formatInline`) now catches a format-time error from a missing/mistyped variable and degrades to the raw template instead of crashing the whole page — mirroring the existing tolerance for malformed-ICU parse errors. So a single bad interpolation can never white-screen a page again.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-i18n-missing-var-crash.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"97712630-fc99-4915-8421-34411a25de55","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-create-update-atomic","type":"fixed","scope":"sales","summary":"Creating and editing an invoice are now atomic — an edit can no longer wipe a draft's line items, and concurrent creates no longer error on a number clash.","body":"Two invoice write-path fixes:\n\n- **Editing a draft could destroy its lines.** `sales.invoice.update` replaced line items by running a `DELETE` of the old lines and an `INSERT` of the new ones on the bare connection. If anything failed between the two (or the row vanished concurrently), the `DELETE` had already committed and the invoice was left with **no line items** — silent data loss on an ordinary edit. The line replacement and the header update now commit as one transaction, so any failure rolls back to the original lines.\n\n- **Concurrent creates could error on a number clash.** `sales.invoice.create` wrote the header, the lines, and the `sales.invoice.created` event as three separate statements, and a number collision (two creates computing the same `INV-YYYY-NNNN` in the read→insert gap) surfaced as an unmapped database error. Create now commits the header + lines + event in one transaction and retries on a number collision (recomputing the next number on a clean rollback), so the second create simply takes the next number instead of failing.\n\nPhase 1.6/1.7 of the Clients/Sales hardening plan. Covered by new real-database integration tests (sequential number allocation, header+lines+event persistence, atomic line replacement, and the non-draft line-edit refusal leaving lines intact).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-create-update-atomic.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9ab45d3b-32c9-4fb1-b97b-1697fe741f8e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-publish-webhooks","type":"added","scope":"website","summary":"Phase 17.E.2 — outbound HMAC-signed publish webhooks. Fires on `website.page.published` so external systems can trigger rebuilds + CDN purges.","body":"Operators with downstream systems (Cloudflare Pages deploy\nhooks, Algolia reindex, custom CDN purge, Linear ticket\ncreation) historically had to write a custom integration to\nreact to a CMS publish event. The platform already emitted\n`website.page.published` via the internal bus, but there was no\noperator-configurable HTTP fan-out.\n\nThis commit lands the missing fabric end-to-end.\n\n**Schema** — new `website_publish_webhooks` table (migration\n`0215_0216`). Per-org rows with `url`, `secret` (HMAC-SHA-256),\n`enabled`, `event_classes` (text[] — currently only\n`website.page.published`, declared as array for future-proofing),\nand `lastFiredAt` / `lastStatus` / `lastError` for at-a-glance\nhealth. No separate deliveries table — at marketing-site scale\nthe per-row health stamp is enough.\n\n**Permission** — new `platform:website:webhook:manage` covering\nCRUD + the manual test-fire. Root-only by default like every\nother `platform:website:*` permission.\n\n**Actions** — 5 new actions in `modules/website/src/actions/`:\n- `create` mints a 32-byte hex secret (or accepts an\n  operator-supplied one) and returns it ONCE.\n- `update` patches name / url / enabled / event_classes;\n  `rotateSecret: true` mints a new HMAC and returns it ONCE.\n- `delete` soft-deletes.\n- `list` returns rows with secrets MASKED (last 4 chars only).\n- `test` fires a sample POST immediately + records the result\n  back to the row so the operator can verify endpoint\n  configuration.\n\n**Dispatch helper** — `modules/website/src/lib/webhook-dispatch.ts`\nexposes `fireWebhookDelivery({ url, secret, body, eventClass? })`\nreturning `{ status, durationMs, error? }`. POSTs JSON; sets\n`X-Helios-Event` + `X-Helios-Signature: sha256=<hex>` headers;\n10s timeout; never throws.\n\n**Subscriber** — `modules/website/src/jobs/publish-webhook-\ndispatch.ts` subscribes to `website.page.published`, pulls every\nenabled row whose `event_classes` includes the envelope class,\nfires every webhook in parallel (failure-isolated), and persists\nthe outcome onto each row. Registered automatically via\n`registerWebsiteJobs({ db })` at worker boot.\n\n**Admin UI** — `/saas/website/webhooks` with list view + new-\nwebhook dialog + secret-reveal dialog (the \"shown ONCE\" warning\nwith copy-to-clipboard) + rotate-secret affordance + test-fire\nbutton + enable/disable toggle + soft-delete. New \"Webhooks\" pill\nin `WebsiteAdminNav`.\n\n**Security** — HMAC-SHA-256 signature lets receivers verify\nauthenticity (timing-safe equal check on the recomputed header).\nSecrets stored verbatim today; future Phase E1 encryption pass\nwill wrap them.\n\n**Tests** — 10 new tests covering create / update / list (secret\nmasking) / delete / test (with mocked fetch). 302 / 20 website\ntests green.\n\nWire format documented in `webhook-dispatch.ts` and surfaced in\nthe secret-reveal dialog for operator onboarding.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-publish-webhooks.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b3595caa-5396-4322-b28f-61d3bf1a0e86","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-draft-edit","type":"added","scope":"sales","summary":"Draft invoices can now be edited in place — change line items, title, and dates without deleting and recreating.","body":"A draft invoice's detail page now has an **Edit draft** button that opens the same line-items editor used when creating an invoice, pre-filled with the draft's current lines, title, issue date, and due date. Saving sends the changes through `sales.invoice.update`, which replaces the lines and recomputes the totals in one transaction. Previously the action existed but had no UI affordance, so the only way to fix a draft was to delete it and start over. Editing is offered only on drafts (and to users with invoice-manage permission); issued invoices stay locked. Phase 3.1 of the Clients/Sales hardening plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-draft-edit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7985c71d-42c0-4566-be9c-af19773d2102","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-list-cursor-pagination","type":"fixed","scope":"sales","summary":"The invoice list can now page through the full history — invoices older than the first page were previously unreachable.","body":"`sales.invoice.list` returned only the newest `limit` invoices with no way to fetch the next page, so any org with more invoices than a single page (default 100, max 500) could not reach its older invoices through the list at all. The action now supports keyset pagination: it returns a `nextCursor` and accepts a `cursor` to fetch the following page, walking the full history in `(issueDate DESC, id DESC)` order. The id is the tiebreaker, so invoices sharing an issue date are never skipped or duplicated across a page boundary, and the cursor composes correctly with the status / company / currency / date / search filters. The change is additive — callers that pass only `limit` get the first page exactly as before. Phase 2.2 of the Clients/Sales hardening plan; covered by real-database integration tests that page across a same-date boundary.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-list-cursor-pagination.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fef25db8-06fa-4f6f-92b4-93026cea36c4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-list-load-more","type":"added","scope":"sales","summary":"The invoice list now has a \"Load more\" control, so orgs with more invoices than one page can reach their full history.","body":"The invoice list page fetched a single page and stopped, so any org with more invoices than the page size could not see its older invoices. It now uses the new keyset cursor: a **Load more invoices** button at the bottom of the list fetches the next page and appends it, walking the full history in issue-date order. Search and the draft-selection tools operate over everything loaded so far. Completes the user-facing half of the invoice-list pagination fix (Phase 2.2 / 3.3 of the Clients/Sales hardening plan).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-list-load-more.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3d9f45f9-481f-4542-b9c0-6cee4e79d967","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-numbering-9999-and-bulk-issue-atomic","type":"fixed","scope":"sales","summary":"Invoice / quote / credit-note numbering no longer breaks past 9999 in a fiscal year, and bulk-issue promotes each invoice atomically.","body":"Two sales hardening fixes:\n\n- **Numbering past 9999.** `nextNumber` picked the next sequence by taking the lexicographic maximum of the existing numbers (`ORDER BY number DESC`). With zero-padded 4-digit sequences that silently broke the moment an org issued its 10,000th document in a fiscal year: `INV-2026-9999` sorts *after* `INV-2026-10000`, so the code recomputed `10000` forever and the org could no longer issue an invoice, quote, or credit note for the rest of the year (every attempt collided on the unique number index). It now orders by the numeric suffix, so the sequence climbs correctly past the 4-digit boundary; a regex guard keeps the cast safe against manually-imported non-numeric numbers.\n\n- **Atomic bulk issue.** `sales.invoice.bulk_issue` flipped each draft to `issued`, promoted its company to `customer`, and emitted `sales.invoice.issued` as three separate unbatched writes. A mid-row failure could leave an invoice marked issued with no `issued` event — silently starving the subscribers that depend on it (revenue recognition, CRM promotion). Each row now commits in its own transaction (with a status re-check so a concurrent issue can't double-emit); a failed row is reported as skipped and the batch carries on, so the action stays safe to retry.\n\nPhase 1.4 of the Clients/Sales hardening plan. Both are covered by new real-database integration tests (numbering across the 9999→10001 boundary, org scoping, non-numeric-tail safety; bulk-issue promotion, skip reasons, retry idempotency).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-numbering-9999-and-bulk-issue-atomic.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f562d947-61d7-46c8-9de0-b164de883490","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-pdf-payments-section","type":"added","scope":"sales","summary":"A paid or part-paid invoice PDF now lists each recorded payment (date, method, reference, amount), not just an aggregate \"Paid\" total.","body":"The invoice PDF previously showed only a single aggregated \"Paid\" line in the totals block. It now prints a **Payments received** section beneath the totals listing every recorded payment — date, method, reference, and amount — with a \"Total received\" line when there is more than one. This matches the per-payment detail the operator already sees on the invoice detail page and the recipient sees on the public invoice link, so the downloaded/emailed PDF tells the same story. Applies to both the operator (`sales.invoice.render_pdf`) and the public token-gated (`sales.invoice.public.render_pdf`) PDFs via the shared builder. Covered by the PDF render smoke-tests.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-pdf-payments-section.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1fa7c623-05fd-4e09-9d6d-80ae2df8d68c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-report-external-payment","type":"added","scope":"sales","summary":"The public invoice link's \"I've paid externally\" button now opens a real reporting flow that the operator can verify and record.","body":"On the public invoice page, \"I've paid externally\" previously fired a contentless, feedback-less event — to the recipient it did nothing, and the operator got a bare note they couldn't act on. It's now a proper flow:\n\n- **Recipient:** the button opens a short form (amount — defaulting to the balance, method, reference, date, note) and submits a token-gated `sales.invoice.public.report_external_payment` claim, then shows a clear confirmation that the sender will verify it.\n- **Operator:** the claim appears on the invoice's activity timeline as an actionable card showing the reported amount / method / reference / date, with a **\"Record this payment\"** button that opens the record-payment modal pre-filled from the claim (with a \"verify the funds arrived first\" reminder).\n- **Sync:** confirming records a real payment via `sales.payment.record`, so the invoice balance + status update and the recipient sees the payment in the ledger. The claim itself never mutates invoice state — verification stays with the operator.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-report-external-payment.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3e8a4f8c-7d1c-44c6-b136-667aa09601fd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-write-off-guardrail","type":"fixed","scope":"sales","summary":"Writing off an invoice now opens a reason-gated confirmation (it was a one-click action that silently failed), and both void and write-off warn when applied credits will be released.","body":"The invoice **Write off** button fired a one-click mutation that sent no reason — but `sales.invoice.write_off` requires one, so the action silently failed validation every time and nothing was written off. It now opens a confirmation dialog (mirroring Void) with a required reason field, danger styling, and a clear \"this cannot be undone\" warning, so write-off both works and asks before destroying a balance.\n\nBoth the Void and Write-off dialogs now also show how much **applied credit will be released** back to its credit note(s) when the invoice is terminated — so the operator isn't surprised when voiding an invoice frees up a previously-applied credit to be re-used elsewhere. Phase 3.2 of the Clients/Sales hardening plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-invoice-write-off-guardrail.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"67258986-09e0-4cb9-a2e0-66da51d33fec","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-visual-polish-round-1","type":"changed","scope":"website","summary":"Marketing site polish — Pill chips pick up faint primary tint, Featured pill normalized, blog card meta strip split into hierarchy groups, newsletter card gains primary-edge accent.","body":"First polish pass after the branding shift in flight. Four targeted\ntweaks from the look-and-feel audit, every change a token-aware\nadjustment that respects dark mode + reduced motion + the existing\ndesign language.\n\n1. **Pill chips (default tone) tinted** — was\n   `border-border bg-surface text-fg` (neutral grey on off-white),\n   now `border-primary/15 bg-primary/[0.025] text-fg`. The 2.5%\n   primary tint reads as \"placed\" without competing with the\n   active-state `tone='primary'` variant (which keeps its 8% tint\n   + full primary border). Affects every blog card tag chip,\n   changelog tag chip, marketing-site tag chip uniformly.\n2. **Featured pill normalized** on the /blog index — was a\n   tight 20px-tall uppercase mono with tracking-wide; now matches\n   the Pill aesthetic at 24px tall, semibold non-uppercase, ★ glyph\n   nudged left of the label, plus a soft primary shadow for lift.\n   Reads as a chip first, badge second — same design system rather\n   than two competing types.\n3. **Blog card meta strip split** into two groups — date + author\n   left (the byline / scan anchor), reading-time + Featured pill\n   right. Wide layouts get clear scan order; narrow viewports wrap\n   gracefully with the Featured pill jumping to its own line.\n4. **Newsletter signup card** — was `border-border bg-bg p-6`, now\n   `border-primary/15 bg-primary/[0.03]` with a thin\n   `bg-primary/70` left-edge accent stripe + a faint primary\n   shadow. Ties visually to the blog-card top-accent language;\n   feels intentional rather than dropped-in.\n\nPure UI; no schema, no actions, no test changes. 46/46 marketing\ntests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-marketing-visual-polish-round-1.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"817ffcfd-7d60-4563-9029-d4ca84fe81e6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-visual-polish-round-2","type":"changed","scope":"website","summary":"Marketing site polish round 2 — Hero / CtaFooter atmospheric tuning for the warm amber palette + heliosworks.com fallback leaks fixed in 404/500.","body":"Second polish pass driven by the branding shift to Odexy (amber\n#FBA82C primary on ink). Warmer primary hues read hotter than indigo\nat the same alpha, so the hero + closing-CTA back-glows need a small\nopacity tweak to land right.\n\n**Hero (`apps/marketing/src/components/blocks/hero.tsx`):**\n\n- Top radial spotlight steps from 18% → 14% primary so the page\n  opens without the amber feeling shouty.\n- New complementary bottom-edge wash at 6% primary grounds the\n  composition. Two glows + the dotted-grid mask now form a soft\n  \"atmospheric envelope\" instead of a single hot spot.\n\n**CtaFooter (`apps/marketing/src/components/blocks/cta-footer.tsx`):**\n\n- Adds a thin gradient hairline rule across the top edge\n  (`from-transparent via-primary/30 to-transparent`) so the closing\n  block visually detaches from the page above instead of running\n  edge-to-edge with no separator.\n- Bottom radial bumps 14% → 16% primary for a small additional\n  presence boost in amber.\n\n**Error pages (`404.astro`, `500.astro`):**\n\n- **Brand-leak fix**: hardcoded `'hello@heliosworks.com'` +\n  `'https://status.heliosworks.com'` fallbacks removed per the\n  no-static-branding rule. Empty fallback → the affordance is\n  hidden rather than leaking the codename's defaults.\n- 404 numeral picks up a softer torch-effect shadow tuned for\n  warm hues (radius 60→80, alpha 0.30→0.40, plus a 0.15-alpha\n  secondary shadow at +2px for hint of physical weight).\n- 500 numeral picks up matching shadow tweaks against the danger\n  color so the two error pages feel like siblings.\n- Letter-spacing tightens from `-0.05em` → `-0.06em` on both\n  numerals for a more editorial cut.\n\nPure UI; no schema, no actions; 46/46 marketing tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-marketing-visual-polish-round-2.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"15cef1ba-5da7-453b-9115-85a268d75550","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-visual-polish-round-3","type":"changed","scope":"website","summary":"Marketing site polish round 3 — changelog version chip unified to Pill aesthetic, BREAKING badge uses tokens, ClosingCall radial tuned for amber.","body":"Third pass after the branding shift. Two consistency wins + one\natmospheric tweak.\n\n**Changelog (`apps/marketing/src/pages/changelog.astro`):**\n\n- **Version chip unified** — was `h-6 bg-surface px-2.5` (a plain\n  grey chip), now `h-7 border-primary/15 bg-primary/[0.025] px-3`\n  matching the new Pill default. The CMS-source changelog header\n  now reads as a cohesive metadata strip (date + version + tags\n  all in the same visual language) instead of three competing\n  styles.\n- **BREAKING badge fixed** — was using hardcoded `bg-red-100` +\n  `text-red-700` Tailwind utilities (raw color literals, against\n  the design-token contract). Now uses the danger token:\n  `border-danger/30 bg-danger/10 text-danger`. Works correctly in\n  dark mode + high-contrast; flows through tenant-overridden\n  danger color when the branding shifts.\n\n**ClosingCall (`apps/marketing/src/components/blocks/closing-call.tsx`):**\n\n- Bottom radial back-glow tuned from 18% → 14% primary to match\n  the hero's amber-palette tuning from round 2. Matters because\n  the home page opens with the hero and closes with this\n  ClosingCall — they need to rhyme. Now they do.\n\nPure UI; no schema, no actions; 46/46 marketing tests green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-marketing-visual-polish-round-3.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7b269ae6-3009-4c1c-87d4-a0fd0c6ef055","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-money-path-atomic","type":"fixed","scope":"sales","summary":"Recording, deleting, and refunding a payment are now atomic and lock the invoice, so concurrent money operations can no longer corrupt an invoice balance.","body":"`sales.payment.record`, `sales.payment.delete`, and `sales.payment.refund` each read the invoice's `amount_paid_cents` on a bare connection, computed a new total, and wrote it back with no lock — a classic lost-update. Two operations racing on the same invoice (e.g. a payment landing while a credit note is applied, a re-delivered gateway webhook, or a double-clicked refund) could clobber each other's balance, leaving the invoice over- or under-paid and its `issued/partial/paid` status wrong.\n\nAll three now run in a single `db.transaction` that locks the invoice `FOR UPDATE` — the one serialization point for every balance mutation — re-validates under the lock, and recomputes the balance from the locked row:\n\n- **record** re-checks the status + reference-idempotency under the lock (a duplicate webhook returns the existing payment and never double-counts), and emits `sales.payment.recorded` / `sales.invoice.paid` on the transaction-bound outbox so the receipt is atomic with the recorded payment.\n- **delete** re-reads the payment under the invoice lock, so a double-delete of the same payment can never subtract its amount twice.\n- **refund** re-sums prior refunds under the lock, so concurrent refunds can never exceed the payment's refundable cap.\n\nThis also closes the previously-flagged gap where credit-note apply/distribute already locked the invoice but a concurrent `recordPayment` did not — both paths now take the same lock, so a payment and an applied credit compose coherently on `amount_paid_cents`. Phase 1.3 of the Clients/Sales hardening plan; covered by real-database integration tests (balance round-trip, idempotency-no-double-count, double-delete safety, refund cap, and payment+credit composition).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payment-money-path-atomic.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"21de5f4a-3316-44f3-b2f0-a205e935908d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-no-mutate-terminal-invoice","type":"fixed","scope":"sales","summary":"Deleting or refunding a payment can no longer silently re-open a voided or written-off invoice.","body":"`sales.payment.delete` recomputed the invoice status purely from the new paid total, so deleting a payment on a `void` / `written_off` invoice would flip it back to `issued`/`partial` — quietly resurrecting a terminal document. Both `sales.payment.delete` and `sales.payment.refund` now refuse with a conflict when the parent invoice is void or written off; reconcile the payment via its refund record instead. This closes the loophole that could otherwise undo the new void/write-off credit-reversal behaviour.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payment-no-mutate-terminal-invoice.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2c1e87cb-51fc-4530-9395-9dec931501d2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-admin-error-states","type":"fixed","scope":"payments","summary":"Payment admin cards now show a clear error when data fails to load, instead of looking empty.","body":"# Payment admin: distinguish \"failed to load\" from \"no data\"\n\nEvery card on the payments admin page (providers, routing, charges, failed payments, disputes) and the provider-picker dialog rendered an empty state when its query **failed** — indistinguishable from a genuine \"nothing here yet\". An operator whose request timed out would think they had no providers/charges. Each now shows a distinct red error callout (with the failure message) when the underlying query errors, so a fetch failure is obvious and recoverable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-admin-error-states.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8b5af109-dffe-4736-8b2c-d93d378e5fd2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-catalog-from-runtime","type":"changed","scope":"payments","summary":"The provider connection form reads its field manifests from the live code, so form changes take effect on deploy without a descriptor re-seed.","body":"# Provider catalog served from the runtime\n\n`payments.provider.catalog` now builds the provider list (form manifests, capabilities, display order) from the in-process provider runtime (`ALL_PROVIDERS`) instead of the seeded `payments_provider_descriptors` table.\n\nThis means a change to an adapter's form manifest — added/removed fields, preset definitions — takes effect on the next deploy automatically, rather than waiting for the descriptor seeder to re-run (which is gated behind the encryption assert and only fires on web boot). The DB table remains a write-through cache. It also removes a redundant DB round-trip from the admin form load and a stale-form edge case where manifest fixes appeared not to apply until a re-seed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-catalog-from-runtime.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"170547fd-ab35-4a2e-b175-6a4f919ffcf4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-checkout-page-ux","type":"fixed","scope":"payments","summary":"The public checkout pages now show customer-friendly errors with a retry, and a clear message for expired sessions.","body":"# Public checkout page UX\n\n- **Inline (Elements) checkout** no longer shows technical, admin-blaming errors to the buyer (e.g. \"publishable key is missing — admin must configure it\"). Configuration/load failures now read as friendly, recoverable copy, with the technical cause logged server-side for the operator. A failed Stripe.js load gets a **Try again** button (it previously dead-ended), while a card decline keeps the **Pay now** button so the buyer can retry.\n- **The return page** now has an explicit **\"this payment link expired\"** state (it previously showed \"Confirming your payment…\" forever for an expired session), and the \"still processing\" state shows the session id as a support reference.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-checkout-page-ux.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5f12d85a-2f00-4984-98fa-5f90b9024727","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-failed-visibility","type":"added","scope":"payments","summary":"The payments admin page now shows failed payments (with decline reason + retry status), and metrics report a real failed count.","body":"# Failed-payment visibility\n\nThe payments admin surface only showed successful captures, so operators couldn't see *why* a payment failed. Now:\n\n- A **\"Failed payments\" card** lists recent failed intents with the provider's decline reason, the amount, and retry status (pending retry vs retries exhausted).\n- `payments.metrics.read` returns a real **`failedCount`** alongside the now-real success rate.\n- `payments.intent.list` gained the platform-admin `orgId` override (parity with `payments.charge.list`), so `/saas/platform/payments` can surface the platform tenant's failed payments too.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-failed-visibility.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d19126c8-36e9-4143-8fa1-e0066b7e9e91","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-form-a11y","type":"fixed","scope":"payments","summary":"Provider-form toggle and dropdown fields are now properly labelled for screen readers and click-to-toggle.","body":"# Provider connection form accessibility\n\nIn the payment-provider connection form, boolean fields rendered the label as plain text not bound to the toggle, and select fields relied solely on the surrounding label. Now the boolean label is bound to its `Switch` (`htmlFor`/`id`) — so clicking the label text toggles it and assistive tech announces the control's name — and select fields carry an explicit `aria-label`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-form-a11y.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2c22c349-c8ea-4aa1-9487-1a4ad8de054d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-metrics-success-rate","type":"fixed","scope":"payments","summary":"The payments dashboard success rate is now computed from real intent outcomes instead of a hardcoded placeholder.","body":"# Real payment success rate\n\nThe admin payments metrics `successRate` was a Phase-1 placeholder that returned `1` (100%) whenever any charge existed. It now reflects reality: `succeeded / (succeeded + failed)` over the selected window, computed from the `payments_intents` log (the charge ledger only records successes, so it can't express a rate on its own).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-metrics-success-rate.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"04b620ff-6c3d-4162-bcaa-7194fdc89817","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-paddle-razorpay-subscriptions","type":"added","scope":"payments","summary":"Paddle and Razorpay now support provider-native plans + subscriptions, not just one-time charges.","body":"# Paddle + Razorpay subscription billing (Q12)\n\nBoth adapters gain `planEnsure` / `planArchive` / `subscriptionCreate` / `subscriptionChange` / `subscriptionCancel` and flip `capabilities.subscriptions` to `true`, so `payments.plan.sync` and `payments.subscription.sync` work for tenants billed through them — not just Stripe.\n\n- **Paddle** (Billing API v2): products + recurring prices for the catalog; since Paddle can't cold-create a subscription, `subscriptionCreate` opens a hosted-checkout transaction and returns its URL as `hostedAuthUrl` (the real subscription id arrives via the `subscription.created` webhook). Cancel uses the dedicated cancel endpoint with `effective_from`.\n- **Razorpay** (Plans + Subscriptions API): a Plan is both product and price; `subscriptionCreate` returns the `short_url` mandate-authorization link as `hostedAuthUrl`. Plan archive is a no-op (Razorpay plans are immutable).\n\nFetch-mocked unit tests cover both adapters' `planEnsure` / `subscriptionCreate` / `subscriptionCancel` (endpoints hit + response mapping).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-paddle-razorpay-subscriptions.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4d3eee77-e2c9-4d3a-921c-83cd647a751d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-plan-price-sync","type":"added","scope":"payments","summary":"SaaS plan edits now sync to the payment provider's catalog (Stripe Product + Price).","body":"# Plan ↔ provider price sync (Q3)\n\nWhen a SaaS plan is created, updated, or archived, Helios now pushes it to the platform payment provider's catalog:\n\n- **Stripe adapter** implements `planEnsure` (create/reuse Product + recurring Price) and `planArchive` (deactivate a superseded Price — provider prices are immutable, so a price change creates a fresh one).\n- **`payments.plan.sync`** + **`payments.plan.archive`** (root-only) persist the mapping in `saas_plan_provider_prices` (one active row per plan × provider × currency × interval) and are idempotent — re-syncing an unchanged plan is a no-op.\n- A **SaaS plan subscriber** drives it: on `saas.plan.created`/`updated` it calls `payments.plan.sync` with the plan data (the SaaS module owns the plan table; payments never reads it), and on `saas.plan.archived` it archives the provider price.\n\nBest-effort: free plans (price 0) are skipped, and with no platform provider configured the sync is a clean no-op. This is the catalog half of provider-native subscriptions; subscription creation lands next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-plan-price-sync.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"cfa565b9-d6c5-47eb-b5f6-0e646c9d97d1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-platform-autosetup","type":"changed","scope":"payments","summary":"The first payment provider connected to an org is auto-set as default, and signup hides paid plans until the platform can charge for them.","body":"# Payments \"just works\" on a fresh deployment\n\nConnecting a payment provider used to require manually ticking \"default\", and the platform tenant (which bills signup + SaaS) never got a routing rule — so a freshly-connected provider often couldn't route anything, and paid signup dead-ended with a routing error.\n\n- **First provider auto-defaults** — `payments.provider.create` now marks the first non-deleted provider for an org as the default, so routing always has a fallback (skipped only when a test-mode provider can't be the default in production). This is what makes the platform tenant's first connected provider immediately routable for signup / SaaS billing.\n- **`payments.platform.configured`** — a new public, boolean-only check of whether the platform tenant has a routable provider.\n- **Signup hides un-sellable paid plans** — the plan step treats \"platform payments not configured\" as free-only and skips straight to workspace setup, instead of showing paid plans that fail at checkout. Fail-open: if the check errors, paid plans stay available (the checkout still validates with a clear message).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-platform-autosetup.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d2113d25-10e2-4602-91bd-054e7404263f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-provider-form-mode-presets","type":"fixed","scope":"payments","summary":"Provider connection form — Production/Sandbox presets no longer clear when you type credentials, and no longer cause save errors.","body":"# Provider form: Production/Sandbox presets fixed\n\nThe \"Quick setup\" Production/Sandbox buttons on the payment-provider connection form had two bugs for key-prefix providers (Stripe, Razorpay, Flutterwave, Paystack):\n\n- **The selection cleared the moment you typed/pasted credentials.** The preset's \"active\" state was computed by comparing the credential field values to a stub (e.g. `sk_test_`), so entering your real key broke the match and the checkmark vanished.\n- **It usually failed to save.** The sandbox preset injected stub values into the credential fields (e.g. `whsec_` into the optional webhook secret, `sk_test_` into the secret key) that fail length validation, and pasting into a pre-filled field could corrupt the key.\n\nFix: presets now set the **canonical Test-mode toggle** instead of stuffing stubs into credential fields. A preset carries a reserved `testMode` flag that drives the top-level toggle; the credential fields are never touched, so typing your key never clears the active preset and no stub value reaches validation. All 15 providers' presets were updated — prefix-keyed providers set only `testMode`; environment/sandbox providers set their endpoint field **and** `testMode` so one click selects the mode consistently. Toggling the Test-mode switch directly now also highlights the matching preset.\n\nThe corrected presets take effect once the provider-descriptor seeder re-runs (on the next deploy/worker boot), which re-publishes each adapter's form manifest.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-provider-form-mode-presets.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"99647fea-1e3b-4632-b140-282d4ed8ccd8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-provider-mode-safety","type":"fixed","scope":"payments","summary":"Provider edits keep stored secrets, a test/live key ↔ Test-mode mismatch is blocked, and routing never sends a live charge to a test-mode provider.","body":"# Payment provider test-vs-production hardening\n\nThree fixes to the payment-provider connection layer:\n\n- **Editing a provider no longer requires re-entering every secret.** `payments.provider.update` now merges a submitted config patch over the stored one (per field), so changing one field — or a non-secret field while secret fields are left blank — keeps the stored secrets instead of failing validation on a partial blob.\n- **A key that contradicts the Test-mode flag is blocked at save.** Adapters gained `detectMode` (from the key prefix — `sk_live_`/`sk_test_`, `rzp_live_`/`rzp_test_`, etc. — or a sandbox flag). Create/update reject a live key saved with Test mode on, or a test key saved with Test mode off, with a clear message. The connection test also surfaces a \"Detected live/test keys\" step and fails when it disagrees with the connection's Test-mode flag.\n- **Routing never sends a live charge to a test-mode provider.** A routing rule pointing at a test-mode connection is now skipped (falling through to the next rule, then the default), matching the protection the default fallback already had.\n\nProviders with no detectable convention (or where the environment is an explicit config field) are unaffected — the guard only fires on a genuine mismatch.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-provider-mode-safety.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"735dd100-25a3-445e-8ad8-9865b94ff1dd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-reconcile-multiprovider-fix","type":"fixed","scope":"payments","summary":"A provider-scoped subscription drift audit no longer falsely flags subscriptions billed through a different provider.","body":"# Drift-audit missing-mirror scope fix\n\n`payments.subscription.reconcile` computed its `missing_mirror` finding from the provider-scoped mirror set. When an operator scoped the audit to one provider, a subscription billed through a *different* provider was falsely reported as having no mirror. The membership check now queries mirrors across all providers (a global \"does any mirror exist for this provider sub id?\" check), so scoping the audit to one provider no longer produces false `missing_mirror` findings. Caught by an adversarial review of the drift-audit code.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-reconcile-multiprovider-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1132138b-586a-4489-9921-8d41c335ead7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-refund-failed-provider-match","type":"fixed","scope":"payments","summary":"A failed-refund webhook now matches by (provider, refund id) so it can't flip another org's refund on a cross-provider id collision.","body":"# Refund-failed webhook: match by provider + refund id\n\nThe `refund.failed` webhook branch updated `payments_refunds` matching on `provider_refund_id` **alone**, unlike every sibling webhook case (intent succeeded/failed, refund succeeded, dispute created) which match on `(provider_id, provider_refund_id)`. Since a provider refund id is only unique *per provider* (the table's unique index is `(provider_id, provider_refund_id)`), in a multi-provider / Stripe Connect deployment a refund-id collision across providers could mark the **wrong org's** refund as failed while the real one stayed pending. Now matched on both columns. Found by an adversarial audit of the payments money-path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-refund-failed-provider-match.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1b5764d2-184d-4548-8c30-e446c1fe9cc5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-39-composer-popover-zindex-clip","type":"fixed","scope":"chat","summary":"Round 39 (user-reported) — composer Smart Compose + Schedule Message popovers were vertically clipped by the toolbar's overflow + had inconsistent z-index. Root cause: `overflow-x-auto` on the toolbar implicitly promoted `overflow-y` to `auto`, clipping any `bottom-full` popover.","body":"User reported: \"the chat text area, action buttons near send button. few actions modals need z index improvements. for example ai action button message schedule action button opens a modal but it needs z index improvements. they don't show up properly.\"\n\n### Root cause\n\nThe composer toolbar at [apps/web/src/components/chat/composer-tiptap.tsx:1065](apps/web/src/components/chat/composer-tiptap.tsx#L1065) used `overflow-x-auto` to allow horizontal scrolling on narrow viewports. **Per CSS spec, `overflow-x: auto` paired with the default `overflow-y: visible` promotes `overflow-y` to `auto` too** ([MDN: overflow — \"If `overflow-x` or `overflow-y` is `visible` while the other one is not, the `visible` value is implicitly set to `auto`\"](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow)). The toolbar therefore clipped on BOTH axes — and any popover anchored `bottom-full` (rendering upward above the toolbar's top edge) was vertically cut off.\n\nThe smart-compose popover at `z-30` happened to render in front of the clip but its top portion was sliced; the schedule popover had **no z-index at all** (defaulted to `auto`) so it both clipped AND could be drawn under sibling toolbar elements.\n\n### Fix\n\n**1. Drop `overflow-x-auto` from the toolbar.**\n\nAfter round 30 collapsed the 10-button formatting group behind an `Aa` toggle on `<sm`, the remaining 6 action buttons + send fit on every viewport ≥ 280 px. Horizontal scrolling is no longer needed; the legacy `overflow-x-auto` was the source of the vertical clip bug.\n\n```diff\n- className=\"helios-composer-toolbar flex items-center justify-between gap-2 overflow-x-auto px-2 pb-1.5 pt-1.5\"\n+ className=\"helios-composer-toolbar flex items-center justify-between gap-2 px-2 pb-1.5 pt-1.5\"\n```\n\n**2. Schedule popover — add missing z-index, normalize to design-system token.**\n\n```diff\n+ data-helios-chat-popover\n- className=\"absolute bottom-full right-0 mb-1 w-[220px] …\"\n+ className=\"helios-chat-popover-in absolute bottom-full right-0 z-[var(--z-dropdown)] mb-1 w-[220px] …\"\n```\n\n**3. Smart-compose popover — replace `z-30` literal with the design-system token.**\n\n```diff\n+ data-helios-chat-popover\n- className=\"absolute bottom-full right-0 z-30 mb-1.5 w-[280px] …\"\n+ className=\"helios-chat-popover-in absolute bottom-full right-0 z-[var(--z-dropdown)] mb-1.5 w-[280px] …\"\n```\n\nBoth popovers are now tagged `data-helios-chat-popover` so they pick up:\n- Round 33 dark-mode glass tightening (96% opacity + inset highlight + deeper shadow)\n- Round 22 mobile viewport clamp (`max-width: calc(100vw - 16px)` on `<480px`)\n- Round 32+ standard popover-in entrance animation (`helios-chat-popover-in`)\n\nThe `--z-dropdown` token resolves to `20` (defined in [packages/ui/src/globals.css:317](packages/ui/src/globals.css#L317)), which matches every other chat-popover surface (Cmd+K palette, mention popover, channel-header popovers, etc.). The smart-compose `z-30` literal was a one-off that risked rendering under newer popovers as the system grew.\n\n### Why not Portal-based positioning\n\nPortals (`createPortal` to `document.body` + manual coords via `getBoundingClientRect`) would be more bulletproof but require ~30 LOC of resize-observer + positioning logic per popover. Dropping `overflow-x-auto` is a 1-line change that fixes the root cause for every popover anchored in the toolbar — current and future. The EmojiPicker (which already uses `position: fixed`) showed the alternative path works but was overkill for this footprint.\n\n**Verification:** chat 107/107 tests pass; composer-tiptap.tsx typecheck clean.\n\n**Sources:**\n- [MDN — overflow (the \"visible/non-visible pair\" rule)](https://developer.mozilla.org/en-US/docs/Web/CSS/overflow)\n- User report, 2026-06-04\n- packages/ui z-index scale at [packages/ui/src/globals.css:317-323](packages/ui/src/globals.css#L317-L323)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.639Z","updatedAt":"2026-06-05T01:29:10.639Z"},{"id":"fc4642c2-346c-4914-9065-b8d2bd6e9273","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-subscription-create","type":"added","scope":"payments","summary":"New payments.subscription.sync action opens a provider-native subscription that bills a tenant through the platform provider.","body":"# Provider-native subscription create (Q5/Q9)\n\n`payments.subscription.sync` (root-only) is the outbound half of provider-native recurring billing:\n\n- Resolves the platform provider (default Stripe) and the synced price from `saas_plan_provider_prices` (run `payments.plan.sync` first, else a clear `dependency_failed`).\n- Ensures a provider customer for the billing owner under the platform tenant, then calls the adapter's `subscriptionCreate` (Stripe uses `default_incomplete`, so SCA / first-payment surfaces via webhook rather than throwing).\n- Persists the mirror row in `payments_provider_subscriptions` attributed to the **tenant** org; lifecycle state (active / past-due / canceled) is then driven by the provider's webhooks — the create path emits nothing, so there's no double-emission race.\n- Idempotent per org: a re-call while a non-canceled subscription already exists returns it untouched.\n\nThe shared platform-provider resolver was lifted into `lib/platform-provider.ts` so plan sync and subscription create share one resolution rule.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-subscription-create.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ad835046-2928-4e36-a004-4a948abfa3b8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-subscription-drift-audit","type":"added","scope":"payments","summary":"A daily drift audit reconciles SaaS subscriptions against provider state and surfaces mismatches to operators.","body":"# Subscription drift audit (Q13)\n\n`payments.subscription.reconcile` (root-only, read-only) compares the three sources of subscription truth — `saas_subscriptions`, the `payments_provider_subscriptions` mirror, and (optionally) the live provider state — and returns typed findings without mutating anything:\n\n- **status_divergence** — mirror and SaaS disagree (critical when billing and access disagree, e.g. provider canceled but the tenant still has access).\n- **orphaned_mirror** — an active provider subscription with no SaaS row.\n- **missing_mirror** — a SaaS subscription pointing at a provider sub with no mirror row.\n- **provider_state_divergence** — live provider status differs from the mirror (only when `checkProvider: true`).\n\nA new `subscriptionFetch` adapter method (Stripe, Paddle, Razorpay) backs the optional live cross-check. A daily worker cron runs the audit globally (DB-only, cheap) and rolls findings into the cron heartbeat so drift shows up in the existing cron-health surface — the safety net behind the webhook sync and the trial/grace guards.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-subscription-drift-audit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9fdac04f-30b1-48c8-ab8f-c5a3a8dd988d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-subscription-lifecycle","type":"added","scope":"payments","summary":"Subscription cancels now propagate to the provider, and a grace sweep auto-cancels long-overdue subscriptions.","body":"# Subscription lifecycle: cancel propagation + dunning grace (Q10)\n\nCloses the loop on provider-native subscription lifecycle:\n\n- **Cancel propagation** — `payments.subscription.cancel` (root-only) cancels the provider subscription so billing actually stops. A SaaS subscriber invokes it on `saas.subscription.canceled`. The action is idempotent and loop-safe: it no-ops when nothing is active, when it's already canceled, or when a period-end cancel is already pending — which is what stops the cancel ↔ `subscription.deleted` webhook echo from looping. The inbound subscriber likewise skips an already-canceled SaaS row.\n- **Dunning grace** — `saas.subscription.mark_past_due` now stamps a `grace_period_ends_at` (default 14 days), and a new hourly grace sweep auto-cancels subscriptions that stay `past_due` past it. This is the Helios-side safety net; provider-native dunning normally cancels first via webhook.\n- **Trial race guard** — the trial-expiry sweep now skips provider-backed subscriptions, so it can't spuriously flip a paying tenant to `expired` while the provider is converting the trial to active.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-subscription-lifecycle.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"75f7c683-4fc8-4e68-92a2-2d3aad1309f3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-subscription-signup","type":"added","scope":"payments","summary":"Paid signup can create a provider-native subscription (Stripe Checkout subscription mode), gated behind a default-off flag.","body":"# Provider-native subscription signup (Q9)\n\nPaid signup can now establish a **provider-native recurring subscription** instead of a one-time charge — gated behind `PAYMENTS_SUBSCRIPTION_SIGNUP_ENABLED` (default **off**, so existing signups are unchanged until an operator enables it).\n\nWhen enabled and the plan is synced to a subscription-capable platform provider, the signup checkout opens a hosted **Stripe Checkout session in subscription mode** — which creates the customer, saves the card, charges the first period, and creates the subscription atomically (the correct fix for the card-on-file problem a deferred-subscription approach would have). On completion the `checkout.completed` webhook confirms the session and stashes the new subscription + customer ids; once `saas-on-payments-session-confirmed` creates the org, `payments.subscription.link` records the mirror and stamps `provider_subscription_id` on the SaaS subscription (preserving its status, so a trialing signup stays trialing and the trial-expiry guard skips it).\n\nNew: `ProviderDescriptor.checkoutSubscription` (Stripe), the `checkout.completed` neutral event, `payments.subscription.link` (records an already-created subscription, idempotent), and `createSession`'s subscription-mode branch. The one-time charge path is untouched and remains the fallback.\n\nEnabling in production needs the operator's sign-off on the trial-vs-charge and checkout-UX questions plus a staging validation — see docs/plans/SAAS_PROVIDER_BILLING_SYNC_PLAN.md §9.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-subscription-signup.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"14436909-0fc3-47ca-8218-f486619bb730","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-subscription-webhook-sync","type":"added","scope":"payments","summary":"Provider subscription + invoice webhooks now drive SaaS subscription status (active, past-due, canceled).","body":"# Provider subscription webhooks → SaaS status (Q7)\n\nThe inbound half of provider-native billing sync is wired:\n\n- The payments webhook handler now dispatches `subscription.created` / `subscription.updated` / `subscription.canceled` and `invoice.paid` / `invoice.payment_failed` / `invoice.upcoming`. It upserts a `payments_provider_subscriptions` mirror row (matched by `(provider_id, provider_subscription_id)`, attributed to the row's own org — correct for Stripe Connect) and emits `payments.subscription.{activated,renewed,past_due,canceled}`.\n- The **Stripe adapter** maps `customer.subscription.created/updated/deleted` and `invoice.paid` / `invoice.payment_succeeded` / `invoice.payment_failed` / `invoice.upcoming` onto those neutral event types, so the handler above never branches on Stripe-specific names.\n- A **SaaS subscriber** turns the events into subscription transitions: activated/renewed → `saas.subscription.set` (active), past-due → `saas.subscription.mark_past_due` (dunning grace begins), canceled → `saas.subscription.cancel`. One subscription per org, resolved by org id, best-effort + idempotent.\n\nA renewal (`invoice.paid`) emits `payments.subscription.renewed` without writing a charge-ledger row — recurring renewals have no Helios intent, and the charge ledger requires one. Provider owns the billing cadence; Helios mirrors the resulting state.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-subscription-webhook-sync.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9c8599ed-72f4-4fbb-b01e-c1c47671dc35","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-testmode-default-nonprod","type":"fixed","scope":"payments","summary":"In dev/staging, a test-mode payment provider can now be the org default and receive routed charges — fixing \"No payment provider is configured\" when testing with sandbox keys.","body":"# Test-mode providers are usable in non-production\n\nIn a non-production deployment (dev/staging), connecting a sandbox/test-mode provider (e.g. Stripe with `sk_test_` keys) and adding a routing rule failed at \"Send payment link\" with *\"No payment provider is configured to take this payment\"* — because routing excluded test-mode providers everywhere. That made the full payment flow impossible to exercise without live keys.\n\nNow the test-mode exclusion is gated on the environment:\n\n- **Routing** (`resolvePaymentRoute`) resolves to a test-mode provider — via a rule or the org default — in non-production. In production it still never routes a live charge to a sandbox connection.\n- **Create/update** allow a test-mode provider to be the org default in non-production; the production guard is unchanged.\n- The connection form's \"Default provider\" toggle is enabled for test-mode providers in non-production (the catalog now reports `allowTestModeDefault`), and disabled in production.\n\nProduction behaviour is identical to before. `NODE_ENV` is the gate.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-testmode-default-nonprod.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"263786ec-3c11-4525-bbb2-c9e29d0d9128","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-webhook-secret-dedup","type":"fixed","scope":"payments","summary":"Removed a duplicate webhook-secret field on the provider form that was stored but ignored by signature verification.","body":"# Provider form: duplicate webhook-secret field removed\n\nSix providers (Stripe, Paddle, Razorpay, GoCardless, Airwallex, Mercado Pago) listed `webhookSecret` in their form manifest **and** the connection form renders a dedicated \"Webhook signing secret\" field — so admins saw two webhook-secret inputs. Worse, the manifest one stored into the config blob, but signature verification reads only the dedicated `webhook_secret_encrypted` column. An admin who filled the manifest box saved a secret that verification ignored → inbound webhooks silently failed signature checks.\n\nFix: the duplicate manifest `webhookSecret` field is removed from those six providers — the dedicated field (and its encrypted column) is the single source of truth. The connection test now validates the **real** dedicated secret: `payments.provider.test` merges the stored webhook secret into the config it hands the adapter (and the create dry-run folds in the entered value), so the \"webhook secret format\" check reflects what verification will actually use.\n\nAlso: the form's \"required fields\" save error now names the specific fields instead of a generic message.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-payments-webhook-secret-dedup.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"0bf6e834-1da3-4b58-afc0-9217a19dfbbe","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"pdf-stamp-polish","type":"changed","scope":"sales","summary":"Invoice, quotation, and credit-note PDFs get a refined rubber-stamp status mark and easier-to-read line tables.","body":"Polished the generated invoice / quotation / credit-note PDFs:\n\n- **Status stamp** — the PAID / OVERDUE / VOID / ACCEPTED / DECLINED / APPLIED mark is now an authentic rubber-stamp impression: two concentric ruled frames, a translucent ink fill so the document faintly shows through, a small caption line under the word (e.g. \"Received with thanks\" under PAID, \"Payment due\" under OVERDUE), and a slight off-axis rotation like a hand stamp.\n- **Line items** — alternating rows now carry a barely-there tint (zebra striping) so dense tables are far easier to scan, with the header and rows sharing a small inset for breathing room.\n\nBoth are purely presentational; no totals or money math changed. Added render smoke-tests that assert each template (invoice, quotation, credit note — including the stamped states) still produces a valid PDF.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-pdf-stamp-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5513ab08-8f95-4155-b2e2-669b1cb53b5f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-57-composer-feedback-polish","type":"changed","scope":"chat","summary":"Round 57 — composer interaction feedback polish. Drag-over overlay fades in/out (was snap-mount), Smart Compose trigger fades on empty draft + Sparkle does the ambient AI-breath animation while pending.","body":"Two audit:composer gaps addressed in one round, both about telegraphing state to the user without a spinner or separate component.\n\n### 1. Drag-over overlay fade (audit:composer Gap #2)\n\nThe \"Drop files to attach\" overlay was conditionally rendered (`{dragOver && <div>...</div>}`) — so it appeared instantly on dragenter and vanished instantly on dragleave. Snap. On flaky / partial drags (cursor briefly entering then leaving), the overlay flickered.\n\nNow it's ALWAYS mounted with opacity + visibility toggled by the `dragOver` state plus a 180 ms cubic-bezier transition:\n\n```diff\n- {dragOver && (\n-   <div aria-hidden className=\"...\">...</div>\n- )}\n+ <div\n+   aria-hidden={!dragOver}\n+   className=\"...\"\n+   style={{\n+     opacity: dragOver ? 1 : 0,\n+     visibility: dragOver ? 'visible' : 'hidden',\n+     transition:\n+       'opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), ' +\n+       `visibility 0ms linear ${dragOver ? '0ms' : '180ms'}`,\n+   }}\n+ >\n```\n\nThe `visibility` transition trick (`0ms linear N ms`) holds the element visible during fade-out so the opacity transition has time to play, then snaps `visibility: hidden` so AT doesn't announce the (invisible) overlay. `pointer-events: none` stays so clicks fall through to the editor underneath regardless of state.\n\n### 2. Smart Compose pending + empty visual states (audit:composer Gap #8)\n\nThe Smart Compose button (`✨` next to attach) had two problems:\n\n- **Empty draft state**: click was a no-op (handler early-returns) but the button looked active. Users couldn't tell it was waiting for input.\n- **Pending state**: the Sparkle icon flipped to `weight=\"fill\"` but otherwise looked identical to the open-menu state. No \"AI is working\" signal.\n\nFix:\n\n```diff\n  <div\n    className=\"relative\"\n    ref={triggerRef}\n+   style={{\n+     opacity: empty ? 0.42 : 1,\n+     transition: 'opacity 150ms cubic-bezier(0.16, 1, 0.3, 1)',\n+   }}\n  >\n    <ToolbarButton\n      icon={\n        <Sparkle\n          size={14}\n          weight={pending || open ? 'fill' : 'regular'}\n+         className={pending ? 'helios-chat-ai-sparkle' : undefined}\n        />\n      }\n      label={\n        empty\n          ? tt('chat.smart_compose.empty_hint', 'Type something first, then ✨ to polish')\n+         : pending\n+           ? tt('chat.smart_compose.pending', 'Smart Compose — working…')\n          : tt('chat.smart_compose.aria', 'Smart Compose — AI rewrite')\n      }\n```\n\n- **Empty** → trigger opacity 0.42 (matches the `disabled:opacity-40` Tailwind utility used elsewhere in the composer). aria-label already explained the \"type something first\" reason.\n- **Pending** → Sparkle gets the `helios-chat-ai-sparkle` class — the ambient breath animation from the Ask Helios pane (round 32-era token). Stays in the chat motion vocabulary; no separate spinner icon needed. Tooltip switches to \"Smart Compose — working…\" so hover users see the state.\n\n**Verification:** chat 107/107 tests pass; composer-tiptap typecheck clean.\n\n**Sources:** audit:composer Gaps #2 + #8 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.573Z","updatedAt":"2026-06-05T01:29:11.573Z"},{"id":"cb416856-f568-4c11-8652-4367530b3261","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"quotation-creditnote-write-atomic","type":"fixed","scope":"sales","summary":"Creating and editing quotations and credit notes are now atomic — an edit can no longer wipe a draft's lines, and concurrent creates no longer error on a number clash.","body":"The same write-path hardening just applied to invoices now covers quotations and credit notes — both had the identical bugs:\n\n- **Editing a draft could destroy its lines.** `sales.quotation.update` and `sales.credit_note.update` replaced line items with a bare-connection `DELETE`-then-`INSERT`. A failure between the two left the `DELETE` committed and the document with **no lines**. The line replacement and header update now commit as one transaction.\n\n- **Concurrent creates could error on a number clash.** `sales.quotation.create` and `sales.credit_note.create` wrote header, lines, and the created event separately, and a `QUO-/CN-YYYY-NNNN` number collision escaped as an unmapped error. Both now commit header + lines + event in one transaction and retry on a number collision (recomputing the next number on a clean rollback).\n\nCompletes Phase 1.6/1.7 of the Clients/Sales hardening plan across all three document types (invoice, quotation, credit note). Covered by new real-database integration tests (sequential number allocation, header+lines+event persistence, atomic line replacement, and the non-draft edit refusal leaving lines intact).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-quotation-creditnote-write-atomic.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a8170cb5-c6c7-4a20-bf9d-0dcf2e8d424f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"quotation-draft-edit","type":"added","scope":"sales","summary":"Draft quotations can now be edited in place — line items, title, and dates — matching the new invoice draft editing.","body":"A draft quotation's detail page now has an **Edit draft** button that opens the shared line-items editor pre-filled with the quotation's current lines, title, issue date, and expiry date. Saving goes through `sales.quotation.update`, which replaces the lines and recomputes totals in one transaction. This brings quotations to parity with the invoice draft editing added in the same release; the affordance is offered only on drafts and to users with quotation-manage permission. Phase 3.1 of the Clients/Sales hardening plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-quotation-draft-edit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7549971f-01aa-448a-8fc6-64641524b836","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-apply-credit-note-atomic","type":"fixed","scope":"sales","summary":"Applying a credit note is now atomic + concurrency-safe, with real-database integration tests.","body":"`sales.credit_note.apply` previously did five bare-database writes with its validations read on an unlocked snapshot — a crash mid-sequence could drift the `appliedCents` / `amountPaidCents` invariant, and two concurrent applies of the same note could both pass the stale check and over-apply past its total. It now runs inside one `db.transaction`, locks the invoice then the credit note `FOR UPDATE` (the canonical order shared with unapply + void/write-off), **re-reads and re-validates the running balances under the lock** (so a second apply sees the bumped balance and is refused rather than over-applying), and emits `sales.credit_note.applied` through a transaction-bound outbox so the event commits atomically with the writes.\n\nPhase 1.1 of the Clients+Sales hardening plan. Also adds the first money-path **integration tests** on the new PGlite harness (`pnpm --filter @helios/sales test:integration`): apply→unapply round-trips every balance to baseline, over-apply is rejected, status transitions are correct, and the outbox event is committed — coverage the `fakeDb` unit double structurally cannot provide.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-sales-apply-credit-note-atomic.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fde9a163-0dba-4391-868c-4404a5b0fcc1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-duplicate-section","type":"added","scope":"website","summary":"Phase 16.A.5 — section cards have a Duplicate button that deep-clones the section and inserts it directly below.","body":"Adds a Copy-icon button to every section card header. Click →\ndeep-clones the section's data (structuredClone-safe) and inserts the\nclone at `idx + 1`. The clone is auto-expanded so the operator can\nstart editing it immediately.\n\nUseful for \"I want two feature grids back-to-back, only the headings\ndiffer\" or \"this hero is the template for every section below it.\"\n\nNothing changes if you don't click the button. Pure UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-duplicate-section.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"adb69c3f-65d1-43f4-96f9-6edc8972ef19","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-clients-action-contract-tests","type":"fixed","scope":"sales","summary":"Action-catalog fixes surfaced by new Sales + Clients contract tests (dup entry, stub descriptions, broken example).","body":"Added registry contract tests across the Sales + Clients action manifests (Phase 0.3 of the hardening plan) — they assert unique `sales.*`/`clients.*` names, real (≥30 char) AI-readable descriptions, ≥1 example + tag, and that every example matches its input/output schema shape. They immediately caught and this commit fixes four AI-catalog/OpenAPI defects:\n\n- `payPublicInvoice` was listed twice in the Sales action manifest (showing as a duplicate in the OpenAPI/MCP tool list).\n- `sales.product.delete`, `sales.tax_rate.update`, and `sales.tax_rate.list` had stub (<30 char) descriptions; rewritten to explain behaviour + side-effects for AI callers.\n- `sales.subscription.get`'s example output was an empty `{}` stub that didn't match its schema; replaced with a representative full detail payload.\n\n(The parse checks intentionally tolerate the modules' placeholder example UUIDs, which predate Zod 4's stricter `.uuid()` — a separate example-hygiene cleanup, not shape drift.)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-sales-clients-action-contract-tests.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4708ca4c-d86b-47ac-af57-607c0e8fd466","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-email-helper-dedup","type":"changed","scope":"sales","summary":"Sales outbound emails now share one brand-name + HTML-escape helper, so white-label + escaping fixes can't drift between senders.","body":"The `resolveBrandAppName` white-label guard (byte-identical in 5 sales `*-email.ts` actions) and the `escapeHtml` helper (in those 5 + the mailer) were copy-pasted. They're now single-sourced in `modules/sales/src/lib/email-format.ts`. No behaviour change today, but it removes two latent drift risks: a new brand sentinel now reaches every sender (so an unbranded deployment can't leak the codename into one email but not another), and an HTML-escaping fix reaches every sender at once. Phase 4.1 (partial) of the Clients+Sales hardening plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-sales-email-helper-dedup.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"50627959-24d7-4102-9f13-afa2cdab19ba","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-statement-scope-queries","type":"performance","scope":"sales","summary":"Statement-of-account no longer scans every org payment/credit-note to render one client's statement.","body":"`sales.client.statement_of_account` previously loaded **every** payment, credit-note application, and credit note in the whole org and discarded all but the target client's in JS — three unbounded org-wide scans on tables that grow past 10k rows, so a single statement's latency scaled with org size rather than client size. The three queries now scope directly to the in-statement invoice/note id set via `inArray(...)` (provably the same result set the JS filter produced), backed by the existing `*_invoice_idx` indexes. Phase 2.1 of the Clients+Sales hardening plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-sales-statement-scope-queries.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f8d89918-70b8-4537-89ed-5fbcecd4931e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"signup-paid-plan-no-provider-message","type":"fixed","scope":"payments","summary":"Choosing a paid plan at signup with no platform payment provider now shows a clear message instead of a raw routing error.","body":"# Clear message when paid signup has no payment provider\n\nPaid plans at signup bill through the **platform tenant's** payment provider (configured in the platform payments admin), not a tenant org's settings. When none is configured, `payments.signup.create_plan_checkout` previously surfaced the routing engine's internal string (\"No provider routes signup.plan.purchase × USD for org 00000000-…\") directly to the signup screen. It now returns an actionable, white-label-safe message — \"Paid plans are not available yet — no payment provider is configured for this deployment…\" — and the internal event class + platform sentinel org id are no longer leaked to end users.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-signup-paid-plan-no-provider-message.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d866ad86-1eec-4efb-8c40-da3b191f5997","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-allowedblocks-chip-control","type":"added","scope":"website","summary":"Phase 14.C — multi-select chip control for `meta.allowedBlockTypes` in the page editor (was raw JSON only).","body":"Phase 11 added `meta.allowedBlockTypes` enforcement at write time\nbut the only way to set it was to type the field into the meta\nJSON textarea correctly. Phase 14.C adds a structured chip control\nabove the textarea — 14 toggle buttons (one per block type) that\nread + write through the same `metaText` state.\n\nImplementation keeps the JSON textarea as the source of truth and\nparses on every render so hand-edits and chip clicks stay in sync.\nTolerant parse — malformed JSON leaves the chip control showing\n\"no restriction\" (the textarea's own error UI already flags the\nparse failure).\n\nUX: \"Clear restriction\" link appears when a whitelist is set;\nexplainer copy under the chips swaps between \"Only the selected\ntypes may be added\" (restricted) and \"No per-page restriction —\norg-wide whitelist still applies\" (unrestricted).\n\n`AllowedBlockTypesField` component co-located in\n`apps/web/src/routes/saas/website.$id.tsx`. Block-type list\nduplicated here to avoid pulling the action layer's types across\nthe app↔module boundary; comment notes the canonical source.\n\nNo backend change. 167/167 module tests pass; web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-allowedblocks-chip-control.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ad5d0aaa-70b9-4939-9103-8f017f54f787","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-approval-queue","type":"added","scope":"website","summary":"Phase 14.D — approval queue route, pending_review left-edge accent, rejection-reason banner above the page editor.","body":"Phase 4 shipped the approval workflow (status enum, actions, perm)\nbut left three UX rough edges from the gap audit:\n\n- No dedicated inbox view for approvers — only a status filter on\n  the main list\n- `pending_review` rows didn't visually pop in the table — approvers\n  scanning a long list missed them\n- Rejection reason buried in the revision log; author refreshing a\n  rejected page saw no banner explaining why\n\nPhase 14.D closes all three:\n\n`/saas/website/pending` — dedicated queue route. Lists every page\nwith `status='pending_review'` for the calling org. Card has a\nwarning left-edge accent to match the row accent on the main list.\nPer-row \"Review →\" (approvers) or \"Open →\" (authors) link to the\neditor.\n\nNew \"Pending review\" button on the `/saas/website` index nav row\n(sits with Collisions / Archived / Redirects).\n\nMain page list — every `pending_review` row gets a\n`border-l-4 border-l-warning/70` className. Selected rows still\nget the `bg-accent/5` background; the two layer cleanly.\n\nPage editor (`/saas/website/$id`) — when the row is back in\n`draft` AND the most recent revision is a `Review rejected:`\nsnapshot, surface a danger-tone banner above the editor showing\nthe rejection reason + timestamp. Banner dismisses when the author\nsaves a fresh edit (the new revision pushes the rejection\nsnapshot down the stack).\n\nNo backend change. Web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-approval-queue.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5320d142-5ae9-4a1d-b099-7897dfa14f06","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-blog-admin-ui","type":"added","scope":"website","summary":"Phase 16.E (UI) — /saas/website/blog with Authors / Categories / Tags tabs, sidebar entry.","body":"Wires the Phase 16.E foundation through to operators. The 13 actions\nlanded in the previous commit; this commit ships the admin UI.\n\n**`/saas/website/blog`** — single-route, 3-tab admin:\n\n- **Authors tab** — list w/ active/archived badges. Create + edit\n  dialog with slug (immutable on edit), display name, bio, avatar\n  URL, and twitter / linkedin / github social handles. Archive\n  button on each row.\n- **Categories tab** — list w/ nested badge for child categories.\n  Create + edit dialog with slug (immutable on edit), name,\n  description, and a parent dropdown populated with root-only\n  categories (1-level nesting limit visible to the operator).\n- **Tags tab** — list sorted by usage count desc with \"Nx used\"\n  badges. Delete button only renders when usage_count = 0 (server\n  enforces this too).\n\n**Sidebar entry** \"Blog\" added under Marketing site, after\nSection types.\n\nBlog posts themselves remain under the existing Pages list filtered\nby kind=blog. The blog-meta picker panel on the post editor\n(operator picks author + category + tags + featured image + excerpt\n+ isFeatured by ID instead of editing the JSON meta directly) is\nthe next polish increment and is queued behind the marketing-side\nroutes (`/blog/author/$slug` etc.).\n\n241/241 module tests still green; no schema changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-blog-admin-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5f78c9a1-5498-40fc-a704-c13ca3a09ad3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-blog-featured-and-meta","type":"changed","scope":"website","summary":"Phase 16.E polish — /blog index pins isFeatured posts to the top with a ★ Featured pill and shows excerpt + reading time when set.","body":"The blog meta picker (`9495bda0`) lets operators flag posts as\nfeatured + author an excerpt + set a reading-time. The /blog index\npage wasn't reading any of those yet.\n\nThis commit closes the gap. `apps/marketing/src/pages/blog/index.astro`\nnow reads `meta.isFeatured`, `meta.excerpt`, `meta.readingTimeMin`\nand:\n\n- **Sort** — featured posts pin to the top (and within that group,\n  by `published_at` desc; same for the non-featured tail).\n- **★ Featured pill** — renders on featured cards in the meta strip\n  above the title, primary-colored, inverted bg for contrast.\n- **Excerpt** — overrides the page's `description` on cards when\n  set. Operators can write a longer, narrative `description` for SEO\n  and a shorter, punchier `excerpt` for the card.\n- **Reading time** — shows as a third meta entry (\"5 min read\")\n  next to the date and author.\n\nBackward compatible: pre-16.E rows have none of these fields, so\nthey sort by published_at desc + show the description on cards, as\nbefore.\n\nNo backend changes; pure UI on top of the existing meta blob.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-blog-featured-and-meta.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1bc4ad60-d82f-4c74-9808-01fe45076716","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-blog-foundation","type":"added","scope":"website","summary":"Phase 16.E — blog as first-class. Authors / categories / tags tables + 13 actions + extended BlogMetaSchema.","body":"Foundation of Phase 16.E of WEBSITE_CMS_V2_PLAN. Posts themselves\nremain `website_pages` rows with `kind = 'blog'`; this commit lands\nthe data layer + action surface for everything around them. Admin\nUI + marketing routes ship in the next commit.\n\n**Migration `0211_0212_website_blog_foundation.sql`** — three new\ntables:\n\n- `website_authors` — `(slug unique)` + optional `user_id` link.\n  `socials` jsonb (twitter / linkedin / github / bluesky / youtube /\n  website).\n- `website_blog_categories` — `(slug unique)` + `parent_id` for one\n  level of nesting (hierarchical via self-reference; cycles\n  prevented at the action layer).\n- `website_blog_tags` — `(slug unique)` + denormalized\n  `usage_count`. Sort tags by popularity for the editor + tag\n  strips; flag usage=0 candidates for cleanup.\n\n**Drizzle schema** + 6 type exports.\n\n**Extended BlogMetaSchema** in `sections.ts`:\n- `authorId`, `coAuthorIds` (max 5), `categoryId`, `tagIds` (max 20)\n  — UUIDs referencing the new tables.\n- `featuredImageUrl`, `excerpt`, `isFeatured`, `readingTimeMin`.\n- Legacy `author` string preserved for back-compat with pre-16.E\n  rows.\n- Read-side PageMetaSchema gains the same fields so historical rows\n  load cleanly.\n\n**2 new permissions** — split by surface so a role can grant\njust-authors or just-categories:\n- `platform:website:author:manage` — author CRUD.\n- `platform:website:blog:manage` — categories + tags CRUD.\n\nEditing blog posts themselves still uses the page module's\n`:page:update`.\n\n**2 policies** in `modules/website/src/policies/blog.ts`.\n\n**13 new actions** in `modules/website/src/actions/blog.ts`:\n\nAuthors (6):\n- `website.author.create` / `.update` / `.archive` / `.list` /\n  `.get` / `.get_public` (used by the eventual `/blog/author/$slug`\n  page).\n\nCategories (4):\n- `website.blog_category.create` (with 1-level nesting check) /\n  `.update` (with self-parent guard) / `.archive` / `.list`.\n\nTags (3):\n- `website.blog_tag.upsert` — get-or-create by slug; returns\n  `isNew` so the editor can flash a \"just created\" hint.\n- `website.blog_tag.list_public` — sortable by usage / name / recent.\n- `website.blog_tag.delete` — only when `usage_count = 0`.\n\nRelated posts (1):\n- `website.blog.related_posts` — by-pageId; scores candidates as\n  `(shared tag count) + (same category ? 2 : 0)`; sorted desc;\n  filtered to score > 0.\n\n**21 new tests**; 241/241 module tests green.\n\nWhat does NOT land in this commit (queued for the next):\n\n- **Admin UI** for managing authors (`/saas/website/authors`),\n  categories (`/saas/website/blog/categories`), tags\n  (`/saas/website/blog/tags`).\n- **Sidebar nav** entries.\n- **Marketing routes**: `/blog/author/$slug`,\n  `/blog/category/$slug`, `/blog/tag/$slug`. The existing\n  `/blog/$slug` continues to work — the new meta is optional.\n- **Blog meta panel** in the page editor (author + category + tags\n  + featured-image + excerpt pickers). Today operators edit the\n  JSON meta directly.\n- **`usage_count` maintenance hook** on `website.page.create` /\n  `update` to keep tag counts in sync.\n- **Tag merge** (combine duplicate tags).\n- **RSS enhancements** (author + category + full content in the\n  Atom/RSS feed).\n\nSub-phase **16.E.2 — editorial flow** (assign-to-editor /\nreview-by-author) layers on top once the data layer is settled.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-blog-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3d3575ec-6805-4f71-9b13-df32c5a308a5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-blog-meta-panel","type":"added","scope":"website","summary":"Phase 16.E polish — structured blog-meta panel on the page editor. Operators pick author / category / tags / featured image / excerpt / isFeatured without touching JSON.","body":"The blog foundation shipped tables for authors / categories / tags +\nextended BlogMetaSchema with `authorId`, `categoryId`, `tagIds`,\n`featuredImageUrl`, `excerpt`, `isFeatured`. But the page editor\nonly surfaced these via the raw meta JSON textarea, so most\noperators wouldn't discover them.\n\nThis commit adds **BlogMetaPanel** in\n`apps/web/src/components/website/blog-meta-panel.tsx` — a typed\npicker panel that renders above the meta JSON whenever\n`page.kind === 'blog'`.\n\nWhat the panel offers:\n\n- **Author** dropdown — populated from `website.author.list`.\n  Archived authors are listed but disabled.\n- **Category** dropdown — populated from\n  `website.blog_category.list`. Nested categories show with a `↳`\n  prefix; archived ones are listed but disabled.\n- **Tags** — chip strip + autocomplete input. Typing into the input\n  filters the published tag catalog; selecting a suggestion adds\n  the chip. Enter on a non-matching name calls\n  `website.blog_tag.upsert` (creates the tag, returns the id, adds\n  the chip). Tags show usage counts in the dropdown so operators\n  pick popular ones first.\n- **Featured image URL** + **reading time (min)** as a side-by-side\n  pair.\n- **Excerpt** — 500-char limit, with a hint that it's used in\n  cards + RSS.\n- **isFeatured** checkbox — pins the post to the top of /blog\n  (renderer-side wiring is part of the upcoming marketing-routes\n  commit).\n\nThe panel reads + writes the editor's existing `metaText` state, so\nthe save path stays unchanged — no new actions, no schema changes.\nOperators can still hand-edit the JSON below; the panel re-reads on\nthe next change.\n\nBrowser-side slugify mirrors the server-side regex so the\n\"create tag\" flow produces slugs the action accepts. Defensive\nparsing on the panel's read side — a malformed meta JSON falls back\nto empty defaults rather than crashing.\n\nNo new tests (component is data-routing on top of already-tested\nactions); 249/249 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-blog-meta-panel.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f6d6ade8-7dd2-4e01-ba93-c8c7b07d0d85","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-blog-post-detail-chrome","type":"changed","scope":"website","summary":"Phase 16.E polish — blog post detail page now renders a linked byline + category chip + tag chips that point at /blog/author /category /tag.","body":"The post detail page (`/blog/[...slug].astro`) previously showed a\nfree-text `By {author}` line + unlinked tag chips drawn from the\nlegacy top-level tags column. Phase 16.E shipped author / category /\ntag tables + `meta.authorId` / `categoryId` / `tagIds` but the\npublic renderer didn't consume them.\n\nThis commit closes that gap:\n\n- **New action `website.blog.resolve_post_refs`** —\n  bulk resolver. Given `{ authorId?, categoryId?, tagIds[] }`,\n  returns the matching `{ author, category, tags }` rows in one\n  round-trip. Missing/archived references are simply omitted.\n- **Marketing helper `resolvePostRefs`** in\n  `apps/marketing/src/lib/cms-runtime.ts` — SWR-cached against\n  the standard freshness window.\n- **`/blog/[...slug].astro`** pulls the IDs from\n  `cmsPage.meta` and calls the resolver alongside the page fetch.\n  Also reads `featuredImageUrl` from the structured meta now\n  (legacy `coverImage` kept as fallback).\n- **`BlogLayout.astro`** gains three new optional props:\n  `authorSlug`, `category`, `resolvedTags`. The byline becomes a\n  link to `/blog/author/<slug>` when `authorSlug` is present. The\n  category chip + per-tag chips link to `/blog/category/<slug>` and\n  `/blog/tag/<slug>` respectively. The legacy free-text tag chips\n  still render as a fallback for pre-16.E rows.\n\nBackward compatible: pre-16.E rows (no `authorId`/`categoryId`/`tagIds`\nin meta) keep rendering exactly as before because the resolver\nreturns null/empty and the layout falls back to the legacy paths.\n\n253/253 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-blog-post-detail-chrome.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7336fbfd-d464-4834-b97b-e0014cf38ebd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-blog-public-routes","type":"added","scope":"website","summary":"Phase 16.E polish — public /blog/author/$slug, /blog/category/$slug, /blog/tag/$slug routes + 3 new public actions.","body":"The blog primitives (authors / categories / tags) now have public\nmarketing routes. Visitors land on:\n\n- **/blog/author/[slug]** — author detail page with bio + avatar +\n  their post list.\n- **/blog/category/[slug]** — category landing with the post list.\n- **/blog/tag/[slug]** — tag landing showing #tag + post count +\n  the post list.\n\nMissing slugs render the page chrome with a \"not found\" body + set\nHTTP status 404.\n\nThree new actions in `modules/website/src/actions/blog.ts`:\n\n- `website.blog.posts_by_author(authorSlug, language, limit)` —\n  resolves the author + lists their published posts via jsonb\n  containment on `meta.authorId`.\n- `website.blog.posts_by_category(categorySlug, language, limit)` —\n  same shape for categories.\n- `website.blog.posts_by_tag(tagSlug, language, limit)` — same for\n  tag IDs (the probe is `{ tagIds: [tag.id] }` so PG's `@>` matches\n  posts whose tagIds array includes the id).\n\nAll three return the resolved entity + a uniform `BlogPostCard[]`\nshape (id, slug, title, description, publishedAt, excerpt,\nfeaturedImageUrl, readingTimeMin, authorId, categoryId, tagIds,\nisFeatured) the marketing renderer consumes.\n\nMarketing-side helpers in `apps/marketing/src/lib/cms-runtime.ts`:\n`fetchPostsByAuthor` / `fetchPostsByCategory` / `fetchPostsByTag`\n— SWR-cached against the standard 60s/1h window.\n\nEach Astro page reads from `Astro.locals.runtime.env.HELIOS_CMS_CACHE`\n+ `loadBranding()` so they fit the existing cache + branding\nconventions.\n\n8 new tests; 253/253 module tests green.\n\nWhat's NOT in this commit (queued):\n\n- Author / category / tag chips rendered on the **post detail\n  page** itself (`/blog/[...slug].astro` doesn't yet show \"by Jane\n  in #engineering\").\n- Featured-post \"pinned\" indicator on the main `/blog` index.\n- RSS feed enhancements (per-item author + category + categories\n  vs tags distinction).\n\nSub-phase 16.E.2 — editorial assignment (author-to-editor review\nflow) — also still queued; it's orthogonal to the public surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-blog-public-routes.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f38901b8-0fce-467c-8d45-4c075ae753f9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-collapsible-section-cards","type":"changed","scope":"website","summary":"Phase 16.A.3 — section cards in the page editor are collapsed by default with a one-line content summary; bulk Expand all / Collapse all toolbar.","body":"A 12-section page was previously a wall of expanded forms. Phase 16.A.3\nmakes every card collapsed by default — operators see one row per\nsection with the type badge, block number, and a derived one-line\ncontent summary, and click the chevron to expand the editor for that\nblock.\n\nThe summaries are per-type:\n\n- `hero` / `cta_footer` → the heading (truncated)\n- `prose` → first non-blank line of markdown\n- `feature_grid` → \"N tiles · 3-col\"\n- `module_tiles` → first 5 module slugs\n- `comparison_table` → \"N cols × M rows\"\n- `diagram_flow`, `trust_strip`, `logo_strip`, `faq` → item count\n- `testimonial` → quote prefix\n- `stat` → value + label\n- `pricing_cards` → \"pulls from saas.plan.list_public\"\n- empty/blank values → \"(empty)\" so operators can still spot which\n  block is unfilled\n\nA toolbar above the section list exposes **Expand all** / **Collapse\nall** when there are 2+ sections. The toolbar is hidden for 0 or 1\nsections (no value).\n\nExpansion state lives in component state keyed by index; reorders\nclear it (which is fine — the operator was using indices, not section\nrefs, and indices shift on move).\n\nNo data changes. Pure UI lift.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-collapsible-section-cards.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3ea96e9c-8c1a-47b5-8d16-5248a456a47f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-collisions-admin-ui","type":"added","scope":"website","summary":"Phase 14.B — admin UI at /saas/website/collisions surfaces the slug-collision audit shipped in Phase 9.","body":"Phase 9 populated `website_slug_collisions` but exposed no UI;\noperators had to grep logs. Phase 14.A added list + resolve\nactions; this phase ships the admin route that consumes them.\n\n`/saas/website/collisions` — table of slug-collision audit rows.\nJoins the current page row so each entry shows the page title\n(falls back to \"page hard-deleted\" if the cascade caught it).\nPill tabs toggle between Open (default — needs-attention feed)\nand All (full audit trail with resolved rows muted).\n\nResolve action stamps `resolved_at` + `resolved_by`. Confirm\ndialog explains the audit row stays for the trail; only the open\nfeed loses the entry. Idempotent — operators can re-click without\nside effects.\n\nNew \"Collisions\" button on the `/saas/website` index nav row,\nsitting next to Archived / Redirects / Site settings.\n\nNo new tests for the route component (consistent with the other\nadmin routes — actions are tested in the module). 167/167 module\ntests pass; website typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-collisions-admin-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"20df360c-cea7-49f7-b20a-289810d804ff","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-custom-section-builder-ui","type":"added","scope":"website","summary":"Phase 16.D (UI half) — /saas/website/sections admin with live preview, marketing renderer for custom sections, sidebar nav, helper auto-prefetch.","body":"Completes Phase 16.D. The foundation (previous commit) shipped 9\nactions + the Mustache engine + the new `custom` section type; this\ncommit wires them through to operators + the public site.\n\n**Admin UI** at `/saas/website/sections`:\n\n- List view with category + status filters, fields + slug shown on\n  each row.\n- Inline create + edit dialog with:\n  - JSON textarea for the field schema (v1; structured field-builder\n    UI is later polish)\n  - Mustache template textarea\n  - JSON textarea for sample preview data\n  - **Live preview pane** that calls\n    `website.section_def.preview` after a 600ms debounce; shows the\n    rendered HTML + any unresolved variables\n  - Sample defaults seeded for new defs (customer-quote-style fields\n    + template + data) so operators get a working starter\n- Publish + Archive buttons; danger ConfirmDialog on archive.\n\n**Marketing renderer**:\n\n- `apps/marketing/src/components/cms/website-page-renderer.tsx`\n  learns the `custom` section type and dispatches via\n  `external.sectionDefinitions[slug]` — looks up the resolved\n  definition, interpolates its Mustache template with the section's\n  `data`, wraps in `<section class=\"custom-section custom-section--<slug>\">`\n  for styleability.\n- Missing definition → warning + empty placeholder. Same\n  graceful-degradation contract as `global_ref`.\n- Inline minimal Mustache renderer (kept in lock-step with the\n  module's `mustache-engine.ts`) so the marketing site doesn't\n  depend on the website module.\n\n**Data flow** (`apps/marketing/src/lib/`):\n\n- `cms-runtime.ts` gains `fetchPageSectionDefinitions` —\n  short-circuits to `{}` when the page has no `custom` sections;\n  otherwise fetches the published catalog once per render (cached\n  via SWR with the same 60s/1h window as page reads).\n- `cms-page-helper.ts` auto-prefetches the definitions on every\n  `loadCmsOrFallback` call and returns them as\n  `result.sectionDefinitions`. Existing Astro pages get this \"for\n  free\" without per-route wiring.\n\n**Sidebar nav** gains \"Section types\" under the Marketing site\ngroup, after Templates.\n\n220/220 module tests still green.\n\nWhat's NOT in this commit:\n\n- **Structured field-builder UI** — operators author the field\n  schema as JSON today. A drag-and-drop field-palette + per-kind\n  config sidebar is the obvious polish but adds ~500 LOC; queued\n  for the post-16.E polish phase.\n- **\"Save section as definition…\"** affordance in the page editor.\n  Convert an inline pricing-cards block to a reusable `custom`\n  section in one click.\n- **Field-data form in the page editor.** Operators editing a\n  `custom` section today see a generic JSON textarea (via the\n  fallback BlockBody case). Per-field rendering (matching the\n  definition's field schema) is the most visible polish gap;\n  queued alongside the field-builder.\n\nBoth queued behind 16.E so the blog flow ships first.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-custom-section-builder-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a1743422-bef8-4a37-8cfc-007cbb093b4d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-custom-section-builder","type":"added","scope":"website","summary":"Phase 16.D — admin-defined section block types. Operators build new block shapes via a field-builder + Mustache template, no code.","body":"Foundation of Phase 16.D of WEBSITE_CMS_V2_PLAN. Operators can now\nextend the section catalog without writing TypeScript. Editor UI +\nmarketing renderer for the new `custom` section type land in the\nnext commit.\n\nWhat lands:\n\n- **Migration `0209_0210_website_section_defs.sql`**:\n  - `website_section_definitions` table (per-org, slug-unique,\n    category-indexed)\n  - `website_section_def_status` enum (draft / published / archived)\n  - `is_system` boolean reserved for future code-block mirrors\n- **Drizzle schema** for `websiteSectionDefinitions`.\n- **Per-kind Zod field schemas** in\n  `modules/website/src/schemas/section-defs.ts` — 10 field\n  primitives: text / textarea / richtext / image_url / url / link /\n  boolean / number / select / repeater (one level of nesting).\n- **`fieldsToZodSchema()` helper** in\n  `modules/website/src/lib/section-def-validator.ts` — converts a\n  field-definition JSON array into a strict Zod schema for\n  validating a `custom` section's `data` object at write time.\n- **`renderMustache()`** in\n  `modules/website/src/lib/mustache-engine.ts` — local copy of the\n  email module's template engine (HTML-escape default, `safe`\n  formatter, `{{#var}}` / `{{^var}}` sections, dotted paths,\n  iteration cap on unbalanced sections). Mustache-only — no\n  helpers, no JS — so admin-authored templates can't XSS.\n- **New `custom` section type** in `modules/website/src/schemas/sections.ts`:\n  `{ type: 'custom', definitionSlug: 'cards-3-col', data: { ... } }`.\n  Read-permissive; write-strict validation against the definition's\n  field schema happens in the action layer (next commit's edit\n  hook).\n- **5 new permissions**: `platform:website:section_def:{read,\n  create, update, publish, archive}`. Platform-tier; never in\n  blueprints.\n- **Per-verb policies** in `modules/website/src/policies/section-def.ts`.\n- **9 new actions** in\n  `modules/website/src/actions/section-def.ts`:\n  - `website.section_def.create` — new draft definition.\n  - `website.section_def.update` — content edit with optimistic\n    lock.\n  - `website.section_def.publish` — draft → published.\n  - `website.section_def.archive` — soft-delete.\n  - `website.section_def.list` — admin list with filters.\n  - `website.section_def.get` — admin read by id.\n  - `website.section_def.list_public` — minimal slug + template +\n    fields; used by the editor catalog + marketing renderer.\n  - `website.section_def.preview` — server-renders a template\n    against sample data; reports missing variables.\n  - `website.section_def.usage` — which pages reference this slug.\n- **26 new tests** (14 Mustache engine + 12 action coverage);\n  220/220 module tests green.\n\nNext commit wires:\n1. Marketing renderer dispatches `custom` sections through\n   `renderMustache` + the resolved definition's templateHtml.\n2. Admin UI at `/saas/website/sections` (list + editor with\n   field-builder + template + live-preview panel).\n3. Sidebar entry \"Section types\".\n4. Slash-menu picks up published custom definitions automatically.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-custom-section-builder.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"44344dc1-2eed-44cc-b387-4deb6f4768e6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-editor-keyboard-shortcuts","type":"added","scope":"website","summary":"Phase 16.A.7 — keyboard shortcuts for save / preview / new-tab preview / shortcut cheatsheet in the page editor.","body":"The page editor (`/saas/website/$id`) now wires the standard editor\nkey combos:\n\n- **`Cmd/Ctrl + S`** → save the page (no-op when nothing to save or\n  permission missing).\n- **`Cmd/Ctrl + P`** → toggle the live preview pane open/closed.\n- **`Cmd/Ctrl + E`** → open the preview URL in a new tab (mints\n  fresh token).\n- **`?`** → open a cheat-sheet dialog listing every shortcut.\n\n`?` is suppressed inside text inputs so typing question marks in\nprose stays uninterrupted. Cmd-modified keys override the browser\ndefault (\"Save page as HTML\" for Cmd+S) — operators get the right\nbehaviour.\n\nThe `/` slash-menu shortcut from Phase 16.A.4 is documented in the\nsame cheat-sheet so all editor bindings live in one place.\n\nNo data changes. Pure UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-editor-keyboard-shortcuts.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"de8b066f-eab6-45d8-9991-e65e31ca50b9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-editorial-assignment","type":"added","scope":"website","summary":"Phase 16.E.2 — editorial assignment. Blog meta gains `assignedEditorUserId`; /saas/website/pending shows assignee + \"Mine only\" filter.","body":"The CMS v2 plan reserved a Phase 16.E.2 for editorial workflow on\ntop of the existing `pending_review` plumbing. This commit ships\nthe smallest meaningful slice: an author can nominate the editor\nthey want to review, and editors can filter the approval queue to\n\"only show what's assigned to me.\"\n\n**Schema (`modules/website/src/schemas/sections.ts`):**\n\n- `BlogMetaSchema` extends with\n  `assignedEditorUserId: z.string().uuid().optional()`. Advisory —\n  doesn't override `platform:website:page:approve`. Any approver\n  can still approve/reject regardless of who's assigned.\n\n**Picker (`apps/web/src/components/website/blog-meta-panel.tsx`):**\n\n- New \"Assigned editor (for review)\" `<Select>` populated from\n  `iam.user.list`. Hidden when the caller has no visibility into\n  members (the list call returns empty rather than throwing).\n- Tracks state through the existing `metaText` round-trip — no\n  new action surface.\n\n**Queue (`apps/web/src/routes/saas/website.pending.tsx`):**\n\n- New `Assigned` column resolves the userId to a display name\n  via `iam.user.list`. Falls back to `user XXXXXX…` (truncated\n  UUID) when the lookup is unavailable. Unassigned rows show an\n  em-dash.\n- Rows assigned to the current user render with a primary-tinted\n  pill prefixed with ★ so editors spot their own queue at a\n  glance.\n- New \"Assigned to me\" toggle button — primary when active,\n  filters rows client-side via `meta.assignedEditorUserId === meId`.\n  Persists per-session via localStorage.\n- Toggle hides itself when the caller has no userId AND no rows\n  point at them (no value surfacing a no-op control).\n- Count badge in the toggle label shows how many rows are\n  currently assigned to the actor.\n\n253/253 module tests still green; no schema changes outside the\nexisting strict `.extend()` chain.\n\nWhat's NOT in this commit (queued):\n- Email notification to the assigned editor on `request_review`.\n  Would slot in as a new flow in `EMAIL_FLOWS` and a subscriber\n  under `modules/website/src/jobs/email-on-review-requested.ts`.\n- Re-assign affordance from the queue itself (currently the\n  author edits the post → meta panel to change assignment).\n- Co-editor support (assigning more than one editor).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-editorial-assignment.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7e37e814-c6b9-4fc0-8a90-899c614a0acc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-globals-foundation","type":"added","scope":"website","summary":"Phase 16.B — reusable site-wide globals (header / footer / cta_strip / trust_strip / announcement_bar / logo_strip / custom) — schema, 8 actions, 5 perms, new global_ref section type.","body":"First half of Phase 16.B of WEBSITE_CMS_V2_PLAN — the backend\nfoundation for \"edit once, reference many.\" Admin UI follows in\nthe next commit.\n\nWhat lands:\n\n- **Migration `0207_0208_website_globals.sql`** — new `website_globals`\n  table with per-org slug+language uniqueness, two enums\n  (`website_global_kind`, `website_global_status`), partial index\n  on non-deleted rows.\n- **Drizzle schema** for `websiteGlobals` in\n  `packages/db/src/schema/website.ts`.\n- **Per-kind Zod schemas** in\n  `modules/website/src/schemas/globals.ts` — strict validation for\n  header / footer / cta_strip / trust_strip / announcement_bar /\n  logo_strip; permissive `custom` for admin-defined shapes.\n- **New section type** `global_ref` in\n  `modules/website/src/schemas/sections.ts`. Pages reference a\n  global by slug; the renderer (next commit) resolves it inline at\n  render time.\n- **5 new permissions** under `platform:website:global:*` —\n  `read / create / update / publish / archive`. Platform-tier only;\n  never in `STANDARD_ROLE_BLUEPRINTS`.\n- **Per-verb policies** in\n  `modules/website/src/policies/global.ts`.\n- **8 new actions** in `modules/website/src/actions/global.ts`:\n  - `website.global.create` — new draft (validates per-kind data).\n  - `website.global.update` — content edit; optimistic-locking via\n    `expectedUpdatedAt`; mutates only name + data (slug / kind /\n    language immutable).\n  - `website.global.publish` — `draft|archived → published`; stamps\n    `published_at` on first publish.\n  - `website.global.archive` — soft-delete; reversible via publish.\n  - `website.global.list` — admin list with kind / status / language\n    filters + cursor pagination.\n  - `website.global.get` — admin read by id.\n  - `website.global.get_public` — public read by slug+language with\n    automatic en fallback.\n  - `website.global.usage` — returns pages referencing a slug via\n    jsonb containment. Use before archive.\n- **14 new tests** covering happy path, validation failure, policy\n  denial, conflict (slug uniqueness + optimistic lock), not-found,\n  and the en-fallback path on `get_public`. 181/181 module tests\n  green.\n\nWhat does NOT land in this commit (queued for the next):\n\n- Marketing-side renderer for `global_ref` sections.\n- Admin UI at `/saas/website/globals` + `/saas/website/globals/$id`.\n- Sidebar nav entry.\n- \"Save as global\" affordance on compatible section types in the\n  page editor.\n\nThat's the second half of Phase 16.B; it ships as a follow-up\ncommit so this one stays reviewable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-globals-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1511778d-b0ac-43c6-a8bc-5027a678e45b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-globals-structured-forms","type":"added","scope":"website","summary":"Phase 16.B polish — structured per-kind editor for website globals (header / footer / cta_strip / trust_strip / announcement_bar / logo_strip). JSON textarea stays as the fallback.","body":"Globals admin used to show a JSON textarea + a static sample for\noperators to copy-paste from. Phase 16.B polish layers a typed\npicker form on top — operators pick from typed pickers for the 6\ncommon kinds; the JSON textarea sits below as the canonical\npersistence shape (both views stay in sync because they flow\nthrough the same `dataText` state).\n\nNew component:\n**`apps/web/src/components/website/global-data-panel.tsx`** — one\npanel, switches its rendered fields based on the `kind` prop:\n\n- **header** — logo URL, primary nav links (label/href pairs, add/\n  remove), single CTA (label + href).\n- **footer** — multi-column composer: heading + nested links per\n  column + legal copyright line. Operators can add/remove columns\n  and reorder via the existing controls.\n- **cta_strip** — heading + subheading + primary CTA + optional\n  secondary CTA.\n- **trust_strip** — caption + repeating list of badges (label +\n  optional href).\n- **announcement_bar** — text + optional link (label + href) +\n  variant select (info / warn / announce).\n- **logo_strip** — caption + repeating list of logos (alt + src +\n  text-only toggle).\n- **custom** — no opinionated shape; the operator stays in the\n  JSON view (the panel renders an explainer instead of fields).\n\nPersistence shape is the canonical jsonb under the same per-kind\nserver-side validation that already gates create/update. Empty\nfield values omit the key from the JSON so the action's\nstrict-schema validator stays happy. Defensive parsing on the\npanel's read side: a malformed JSON falls back to empty defaults\nrather than crashing.\n\nMirrors the Phase 16.E **BlogMetaPanel** pattern — no schema\nchange, no new action surface, no server-side test changes;\n253/253 module tests still green.\n\nWhat's left for globals admin UX:\n- Live preview of the global rendered on a typical page (would\n  reuse the page-editor preview pane).\n- \"Save as\" / clone affordance.\n- Bulk export.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-globals-structured-forms.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"deb31e66-74bf-4f23-96c4-5b871d501fef","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-globals-ui-and-renderer","type":"added","scope":"website","summary":"Phase 16.B (second half) — admin UI at /saas/website/globals, marketing renderer for global_ref sections, sidebar nav entry, sections-editor support.","body":"Completes Phase 16.B. The foundation (schema + 8 actions + perms +\nnew section type) landed in the previous commit; this one wires it\nthrough the operator-facing surfaces.\n\n**Admin UI** at `/saas/website/globals`:\n\n- List view with per-kind / per-status filters.\n- Inline create + edit dialog with a per-kind JSON `data` textarea +\n  embedded sample for every kind. Strict server-side validation\n  surfaces field-level errors.\n- Publish + Archive actions on every row; Archive opens a danger\n  ConfirmDialog warning about page references.\n- Sidebar entry \"Globals\" added under the Marketing site group in\n  `apps/web/src/components/modules.tsx`.\n\n**Marketing renderer**:\n\n- `apps/marketing/src/components/cms/website-page-renderer.tsx`\n  learns `global_ref` and dispatches to `<GlobalRefDispatch>`, which\n  inlines the resolved global via the matching component:\n  - `cta_strip` → CtaFooter\n  - `trust_strip` → inline TrustBadge grid\n  - `logo_strip` → LogoStrip\n  - `announcement_bar` → variant-colored top banner (auto-hides past\n    `expiresAt`)\n  - `header` / `footer` / `custom` → warning + empty placeholder\n    (those belong in the layout, not the page body)\n- `apps/marketing/src/lib/cms-runtime.ts` gains `getGlobalPublic` +\n  `fetchPageGlobals` (parallel pre-fetch for every `global_ref` slug\n  in a page's sections array). Same SWR semantics as page reads.\n- `apps/marketing/src/lib/cms-page-helper.ts` auto-prefetches the\n  globals every `loadCmsOrFallback` call and returns them in the\n  result — every Astro page that uses the helper gets globals \"for\n  free\" through `result.globals`.\n\n**Page editor**:\n\n- `SectionsEditor` now offers `global_ref` as a 15th block type\n  with a free-text slug field + helper text linking to the globals\n  catalog. Default section, summary line, slash-menu metadata all\n  wired.\n\nSample data per kind:\n\n```json\n// cta_strip\n{\n  \"heading\": \"Ready to ship faster?\",\n  \"subheading\": \"Start free; no card required.\",\n  \"primaryCta\": { \"label\": \"Start free\", \"href\": \"/signup\" }\n}\n// trust_strip\n{ \"badges\": [{ \"label\": \"SOC 2 Type II\" }, { \"label\": \"GDPR\" }] }\n```\n\nOperators wire a footer global once, reference it from every product\npage, and edit it in one place going forward. 181/181 module tests\nstill green; marketing 46/46.\n\nWhat's NOT in this commit (deferred):\n\n- Per-kind structured forms (still JSON textarea). The action layer\n  is ready; richer UI is polish.\n- \"Save inline section as global…\" affordance on compatible section\n  types in the page editor.\n- Globals revisions table (separate audit trail).\n\nBoth queued for follow-up polish PRs after 16.C / 16.D ship.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-globals-ui-and-renderer.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"553e138b-8e1d-4cc0-86b0-2c99cb0e61f5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-live-preview-pane","type":"added","scope":"website","summary":"Phase 16.A.1 — live preview pane in the page editor with mobile/tablet/desktop viewport selector, pin/unpin toggle, and refresh on save.","body":"The first sub-phase of WEBSITE_CMS_V2_PLAN.md. The page editor at\n`/saas/website/$id` now offers a split-screen layout: form on the\nleft, live iframe of the rendered page on the right.\n\nWhat lands:\n\n- **Sticky right-side preview pane** — toggled via \"Show preview\" in\n  the actions row; choice persisted in `localStorage` so it survives\n  navigation.\n- **Viewport selector** — mobile (375 px), tablet (768 px), desktop\n  (1280 px) buttons with active state; selection persisted.\n- **Live iframe** rendering the draft via the existing\n  `website.page.mint_preview` action; token re-mints every 13 min so\n  long editing sessions don't break.\n- **Refresh on save** — the iframe re-loads automatically every time\n  the page is successfully saved.\n- **Manual refresh** button for re-minting + reloading.\n- **Token expiry hint** in the pane footer.\n- **\"Open in new tab\"** preserved as a separate button alongside.\n\nThe pane reads the SAVED row from the DB — unsaved field edits don't\nappear until Save. Earlier drafts of this PR debounced reloads on\nevery keystroke; removed because the iframe couldn't show unsaved\ncontent anyway and it flickered for no payoff.\n\nNo schema changes. No new actions. Pure UI on top of existing\npreview infrastructure.\n\nTests: 167/167 website module tests still green. The new component\nrelies on the existing `mint_preview` happy-path which is already\ncovered.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-live-preview-pane.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"63c56b48-c5bf-4d36-b100-a6727f8dde81","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-locale-auto-extract","type":"changed","scope":"website","summary":"Phase 15.C — loadCmsOrFallback auto-extracts locale from the request URL so /es/foo, /pt-BR/bar etc. fetch the right localised CMS row without per-route wiring.","body":"Phase 14.E shipped `loadCmsOrFallback({ language? })` but per-route\nwiring (extracting locale from each `.astro` file's path) was\ndeferred — touching every page under `apps/marketing/src/pages/`\nwould have bundled with parallel work.\n\nPhase 15.C avoids the per-route edit entirely by **auto-extracting\nlocale from the request URL inside the helper**. The auto-extract\nruns only when the caller doesn't pass `language:` explicitly, so\nexisting callers are unchanged.\n\nNew helper `extractLocaleFromPath(pathname)` in\n`apps/marketing/src/lib/cms-page-helper.ts`:\n\n- `/es` → `'es'`\n- `/es/pricing` → `'es'`\n- `/pt-BR/blog/foo` → `'pt-BR'`\n- `/zh-CN/...` → `'zh-CN'`\n- `/blog/foo` → `undefined` (default locale)\n- `/api/actions/x` → `undefined` (reserved root)\n- `/og/...` / `/ai/...` / `/rss.xml` → `undefined` (reserved roots)\n\nConservative regex: first path segment must look like a BCP-47\ntag (2-3 alpha + optional `-Region`) AND must NOT be a known\nroot-level path. The reserved list (`api`, `og`, `ai`, `rss`,\n`docs`, `tos`, `dpa`) shadows the BCP-47 shape for paths Astro\nroutes already own.\n\n6 new tests covering the happy paths + reserved-root carve-out +\nmalformed input. 6/6 marketing tests pass; website typecheck\nclean.\n\nThe route files `pages/es/*.astro`, `pages/de/*.astro` etc. still\nneed to exist for Astro to even reach this code (Astro 404s\nunknown paths before the helper runs). The helper now makes those\nlocale-prefixed route files *trivial* to add — copy the source\nroute, change nothing. The hard part is shipped.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-locale-auto-extract.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"40359c57-bb05-4b14-b189-379b70fd1cf6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-preview-and-product-route-fixes","type":"fixed","scope":"website","summary":"Phase 17.A.5/6 — product-route accent ternary fixed; preview route pre-fetches globals + section-defs + plans + returns 404 on missing token.","body":"Three renderer-audit Tier 1 bugs fixed in one commit:\n\n**`/product/[slug].astro` accent ternary (17.A.5)** —\nThe old line `(meta.accentColor ?? branding.appName) ? '#7C3AED'\n: '#7C3AED'` always returned the indigo literal regardless of\nthe operator's `meta.accentColor`. The branch checked truthiness\nof a string and the conditional values were identical. Also the\nschema field is `moduleStatus`, not `status`; both keys now\nwork for back-compat.\n\nFix:\n- `accentColor` reads `meta.accentColor ?? branding.brandPrimary\n  ?? '#7C3AED'` (last resort).\n- `status` reads `meta.moduleStatus ?? meta.status ?? 'shipped'`.\n\n**`/preview/[id].astro` missing external data (17.A.6)** —\nThe preview route passed only `sections` + `allowedBlockTypes`\nto `<WebsitePageRenderer>`. Drafts using `global_ref` blocks,\n`custom` sections, or `pricing_cards` rendered empty —\noperators saw \"broken\" previews and assumed their edits dropped\ndata.\n\nFix:\n- Pre-fetches `globals` via `fetchPageGlobals(sections)` in\n  parallel with `sectionDefinitions` via\n  `fetchPageSectionDefinitions(sections)`.\n- Passes the bundled `plans.json` so `pricing_cards` blocks\n  render their full PricingCard tiles (matching the live\n  /pricing page).\n- Threads all three through the renderer's `external` prop.\n\n**`/preview/[id].astro` HTTP status on missing token (17.A.6)** —\nPreviously returned `200 OK` with a \"Preview not available\" body.\nCrawlers + bare visitors saw a success page with placeholder\ncontent. Fix: `Astro.response.status = 404` when `page === null`.\n\nMarketing 46/46 tests still green; no schema or action changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-preview-and-product-route-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"bb7dee63-e8bb-4db8-9cb4-b540fde4e40b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-32-react-palette-microinteractions","type":"changed","scope":"chat","summary":"Round 32 — quick-react emoji palette gets the iMessage / Facebook tap-and-hold microinteraction treatment: glass chassis, staggered cascade entrance, per-emoji hover scale + spring, tactile press feedback.","body":"Research turned up a clear pattern across 2026 chat UI inspiration sources (Dribbble, Linear, modern iMessage / Facebook / Slack): the tap-and-hold reaction picker is the canonical \"fast reaction\" surface, and every chip in it has its own playful microinteraction. The previous Helios palette opened as a flat rounded-md card with plain `hover:bg-emphasis` on each emoji — functional but visually anonymous.\n\n**What lands:**\n\n- **Glass chassis** — palette now matches the message hover-actions toolbar's frosted-glass family: `color-mix(in oklch, var(--bg-popover) 92%, transparent)` background, 14 px blur + 150% saturation, soft 1 px border, 3-layer shadow. Same as the rest of the chat popover system. Switches the corner radius from `rounded-md` to `rounded-full` so the palette reads as a pill.\n- **Staggered cascade entrance** — `helios-chat-react-emoji` class drives `helios-chat-spring-in` (existing 280 ms cubic-bezier(0.34, 1.56, 0.64, 1) spring), with `:nth-child(1..6)` delays of 0 / 28 / 56 / 84 / 112 / 140 ms. The palette pops in left → right with each emoji having its own bounce.\n- **Per-emoji hover scale** — every emoji button now `hover:scale-[1.18]` with the spring curve. Hover invites the user to press; the chip \"rises\" toward the cursor.\n- **Tactile press** — `active:scale-[0.92]` on press, then the palette dismisses and the chip flies away. The on-message reaction chip's own existing entrance spring (already in place) plays through.\n- **Button size** — `size-7` → `size-8` (28 → 32 px) so the hit target meets the 44 pt iOS spec when including the scale-up; emoji visual size unchanged.\n- **Reduced motion** — all stagger delays drop to 0 ms and the spring collapses to a 80 ms `fadeIn`, matching the rest of the chat module's reduced-motion contract.\n\n**Why staggered:** research keeps surfacing the same finding — \"tiny animations change user decisions\" because they communicate state changes the user might otherwise miss. The cascade tells the eye \"this palette just opened\" without needing a separate sound or color shift.\n\n**Verification:** chat 107/107 tests pass; visual diff: emoji size +4 px, palette radius `rounded-md → rounded-full`.\n\n**Sources researched:**\n- Dribbble Chat App / Messaging tag (3,500+ shots scanned for patterns)\n- Medium \"Dark Glassmorphism: The Aesthetic That Will Define UI in 2026\"\n- Bricxlabs \"16 Chat UI Design Patterns That Work in 2026\"\n- ScienceDirect \"Animations in UI microinteractions as modulators of emotion\"\n- UXPin \"Chat UI Design: How to Build Effective Chat Interfaces in 2026\"","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.668Z","updatedAt":"2026-06-05T01:29:10.668Z"},{"id":"a2c0e067-63bf-45a8-9c03-0f50cd8031a0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-redirect-fast-cache","type":"changed","scope":"website","summary":"Phase 15.D — redirect lookups now use a 10s freshness window so operator-created redirects take effect within seconds, not minutes.","body":"The audit flagged `website.redirect.*` as having no cache-bust path —\nthe marketing site's KV cache held the \"no redirect\" lookup for the\nfull 60s fresh / 1h stale-while-revalidate window. An operator\ncreating `/old-path → /new-path` at `/saas/website/redirects` could\nwait up to an hour to see the redirect activate on the public site.\n\nPhase 15.D narrows the gap without infrastructure work:\n\n- **New per-call option** `freshMs?: number` on `FetchPagesOptions`\n  in `apps/marketing/src/lib/cms-runtime.ts`. Honored by\n  `staleWhileRevalidate`; capped at `STALE_MS` so the absolute cache\n  horizon stays consistent.\n- **`lookupRedirect` uses `REDIRECT_FRESH_MS = 10_000`** — every\n  Astro request runs through the middleware, so redirects re-fetch\n  ~6× per minute per cold cache key (one operator add + ~10s wait =\n  visible). Background fetch is still cheap (single action call,\n  KV-backed).\n- Page content (`getPage`, `getSettings`) untouched — content edits\n  still tolerate the 60s smear since they don't carry the same\n  \"operator just made a change, why isn't it live yet\" pressure.\n\nThe original audit suggestion (worker emits cache-bust → marketing\nHTTP endpoint clears KV key) would require a new Cloudflare Function\n+ worker subscriber + signed-request auth. Deferred until volumes\njustify it; the 10s window already closes the operator-visible gap.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-redirect-fast-cache.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b4211ccc-1f0d-4fb6-b9de-71eda1f2a75e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-review-rejected-email","type":"added","scope":"website","summary":"Phase 16.E.2 polish — author gets an email when an approver rejects their review submission, with the rejection reason verbatim.","body":"The other half of the editorial workflow: when an approver rejects\na pending_review page via `website.page.reject_review`, the author\nwho submitted it now gets an email pointing back to the editor + the\nrejection reason quoted.\n\n**Event** — new `website.page.review_rejected` with payload\n`{ id, orgId, slug, kind, rejectedBy, authorUserId, reason }`. Emitted\nby `reject_review` after the row flips back to `draft`. The\n`authorUserId` is resolved server-side from the most recent\n\"Submitted for review\" revision (the row's `createdBy` is a\nfallback) — the resolution sidesteps the case where the original\ncreator and the active author diverge (e.g. after a translate\nhandoff).\n\n**System template** — `website.page.review_rejected` with a red\nleft-edge accent + the rejection reason quoted in a blockquote +\nthe link to the editor for re-submission. Variable list: orgName,\nrecipientName, pageTitle, pageKind, pageSlug, rejectedByName,\nreason, editorUrl.\n\n**Flow entry** — `website.page.review_rejected` registered in\n`EMAIL_FLOWS` as `important: true`.\n\n**Subscriber** —\n`modules/website/src/jobs/email-on-review-rejected.ts`. Same shape\nas the request-side: skips cleanly when authorUserId is null or\nthe user is deleted/has no email. Idempotency keyed on\n`(pageId, authorUserId, envelopeId)`.\n\n**Worker wiring** — `registerWebsiteJobs({ db })` now also wires\nthe rejection subscriber. apps/worker hasn't changed since the\nprior commit; the registrar handles both subscriptions in one\ncall.\n\n262 / 262 email module tests + 253 / 253 website module tests\nstill green.\n\nQueued (not in this commit):\n- Email to the assigned editor when the rejection is \"soft\" (e.g.\n  \"needs co-editor sign-off\") — currently a single editor flow.\n- \"Approve & publish\" notification to the author (mirrors this\n  pattern with the positive outcome).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-review-rejected-email.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"422065a5-0f3e-4fa5-b688-a4221c7419fb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-review-requested-email","type":"added","scope":"website","summary":"Phase 16.E.2 polish — assigned editor gets an email when an author requests review on a CMS draft.","body":"The editorial-assignment field shipped in the previous commit is\nnow actually useful — when the author taps \"Request review\" on a\ndraft AND has named an editor via the blog meta panel, that editor\nreceives an email pointing at /saas/website/$id for the\napprove/reject flow.\n\nPieces:\n\n- **Event** — new `website.page.review_requested` with payload\n  `{ id, orgId, slug, kind, requestedBy, assignedEditorUserId, note }`.\n  Emitted by `website.page.request_review` after the row flips to\n  `pending_review`. The generic `website.page.updated` still fires\n  so existing subscribers don't break; this is additive.\n- **System template** — `website.page.review_requested` in\n  `modules/email/src/seeds/templates/website.ts`. Carries an amber\n  left-edge accent + the page title/kind/slug + the author's\n  optional submission note quoted. Single primary CTA: \"Open in\n  the editor.\"\n- **Flow entry** — `website.page.review_requested` registered in\n  `EMAIL_FLOWS` with `important: true` (essential operator\n  workflow). Owner module: `website`.\n- **Subscriber** —\n  `modules/website/src/jobs/email-on-review-requested.ts`. Skips\n  cleanly when the row has no assigned editor (we don't blast\n  every approver by default); logs + skips when the editor's\n  account is deleted or has no email. Idempotency keyed on\n  `(pageId, editorUserId, envelopeId)` so the same envelope can't\n  re-fire, but a fresh request after a previous rejection emits a\n  new envelope and therefore a new send.\n- **Worker wiring** — new `registerWebsiteJobs({ db })` registrar\n  in `modules/website/src/jobs/index.ts` called from\n  `apps/worker/src/index.ts` at boot. Future website subscribers\n  land in the same registrar.\n\nPer the email-integration rule:\n- No direct provider SDK imports — goes through\n  `getAction('email.outbound.send')` + `invoke()`.\n- Synchronous in the subscriber → action returns immediately with\n  `status: 'queued'`; the email worker drains.\n- No per-org approver fan-out at this layer (deliberate). When\n  operators want \"every approver hears about every review,\" they\n  can add an `email_routing_rules` entry — that's the right knob.\n\n262 / 262 email module tests + 253 / 253 website module tests\nstill green.\n\nQueued (not in this commit):\n- Email to author on `reject_review` with the rejection reason\n  (mirrors this pattern with the other side of the workflow).\n- Multi-recipient fan-out when multiple co-editors are assigned\n  (currently only the primary `assignedEditorUserId` is honored).\n- Re-assignment notification (currently the picker change just\n  updates the meta; no email until the author re-submits).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-review-requested-email.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3bd71b5a-84db-4455-861f-07f2b1b12ea0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-rss-cms-driven","type":"changed","scope":"website","summary":"Phase 16.E polish — /rss.xml rewritten to read from the CMS at runtime with author / category / excerpt enrichment per item.","body":"The blog RSS feed at `apps/marketing/src/pages/rss.xml.ts`\npreviously read from the MDX content collection\n(`getCollection('blog')`) at build time, so newly-published CMS\nposts didn't appear in the feed until the next deploy.\n\nThis commit cuts over to the CMS:\n\n- **Runtime SSR** (`prerender = false`) — every request fetches the\n  current set of published blog rows. KV-cached via the standard\n  cms-runtime SWR layer (60s fresh / 1h stale) + a `Cache-Control:\n  max-age=3600, s-maxage=3600` so the CDN edge holds it for an hour.\n- **`<title>`, `<link>`, `<description>`** come from\n  `platform.branding` so the feed auto-rebranded for each operator\n  deployment (the old hardcoded \"Helios — Blog\" was the leftover\n  from before the white-labelling effort).\n- **Per-item `<author>`** resolved from `meta.authorId` →\n  `website_authors.displayName`. Falls back to the legacy\n  `meta.author` string, then to `{brand} team` for pre-16.E rows.\n- **Per-item `<category>`** resolved from `meta.categoryId`. Emits\n  with `domain=\"<site>/blog/category/<slug>\"` so feed readers that\n  honor the domain attribute link back to the category landing.\n- **Per-item `<description>`** uses `meta.excerpt` when set (the\n  short narrative form), else falls back to the page's\n  description column (the SEO blurb).\n- **Featured-first sort** — matches the /blog index ordering. The\n  feed shows featured posts above newer non-featured posts.\n\nTags are NOT emitted as `<category>` tags — that field is reserved\nfor the post's single category in our schema. A future enhancement\ncould emit one `<category>` per tag if reader support warrants it.\n\nNo backend changes. Pre-16.E rows continue to surface using their\nlegacy fields.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-rss-cms-driven.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"92be2c34-d462-4f92-aa2d-77ee8f35a260","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-save-as-template-affordance","type":"added","scope":"website","summary":"Phase 16.C polish — \"Save as template…\" button on every page editor. Captures current sections + meta into a reusable template in one click.","body":"Phase 16.C shipped `website.template.create_from_page` but operators\nhad to find it manually (call the API directly or navigate to\n/saas/website/templates and create one by hand). This commit closes\nthat operator-visible gap.\n\n`/saas/website/$id` (the page editor) gains a \"Save as template…\"\nbutton in the action row, visible when the operator has\n`platform:website:template:manage` and the page isn't archived.\n\nClicking opens a ConfirmDialog with:\n\n- **Template slug** — prefilled as `tpl-<kind>-<page-slug>` (kebab\n  case, sanitized to the template-slug regex).\n- **Template name** — prefilled as `Template — <page title>`.\n- **Description** — optional.\n- **isStarter** checkbox — when checked, the resulting template\n  appears with a ★ pill on the `/saas/website/new` gallery for\n  pages of the same kind.\n\nOn confirm, calls `website.template.create_from_page` with the\ncaptured slug/name/description/isStarter — the action handler\ncopies the page's `sections` + `meta` + `tags` into the new\ntemplate row. The new template is independent (no live link back\nto the source page; subsequent edits to either don't propagate).\n\nPure UI — no schema or action changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-save-as-template-affordance.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d589384e-8781-4814-95ea-f505c2ae57f4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-save-section-as-global","type":"added","scope":"website","summary":"Phase 16.B polish — convert an inline cta_footer / trust_strip / logo_strip section into a reusable website_globals row in one click.","body":"Phase 16.B shipped the globals foundation + admin UI + the\n`global_ref` section type, but converting an existing inline\nsection into a reusable global was a multi-step manual chore:\ncopy the section's data, navigate to `/saas/website/globals`,\npaste into the New-global dialog as JSON, save, publish, then\ngo back to the page and replace the inline section with a\nglobal_ref block.\n\nThis commit ships the affordance as a single click.\n\nThe page editor's `SectionsEditor` gains a small ⇪ icon button on\nevery card whose section type maps cleanly to a global kind:\n\n- `cta_footer` → `cta_strip` (heading + subheading + CTAs + trust\n  note all transfer)\n- `trust_strip` → `trust_strip` (caption + badges)\n- `logo_strip` → `logo_strip` (caption + logos)\n\nClicking the icon opens `<SaveSectionAsGlobalDialog>`:\n\n- Prefills slug + name from the section's heading / caption\n  (kebab-cased).\n- On confirm, calls `website.global.create` with the section's\n  data (minus the `type` discriminator — the remaining shape\n  matches the global's per-kind schema exactly).\n- Auto-calls `website.global.publish` immediately so the page\n  renders correctly right after.\n- On success, replaces the inline section in the sections array\n  with `{ type: 'global_ref', globalSlug }`.\n\nIf the auto-publish fails (operator missing `:publish`\npermission), the global stays as draft and a toast tells the\noperator to publish manually — the inline section is NOT replaced\nin that case to avoid a broken render.\n\nHero + other section types stay inline-only because their data\nshapes don't map cleanly to existing global kinds.\n\nNo backend changes; uses the existing globals action surface from\nPhase 16.B foundation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-save-section-as-global.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1aa8bd27-3b20-4c9d-abfc-8c20b99c23f0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-scheduled-queue-view","type":"added","scope":"website","summary":"Phase 14.F — /saas/website/scheduled aggregates every queued auto-publish with a fires-in / overdue countdown.","body":"Phase 2 shipped scheduled-publish (publish_at column + Inngest\ncron + Schedule…/Reschedule…/Cancel affordances on the editor).\nIndividual rows surfaced as a `⏰ scheduled` pill on the main\nlist, but operators had no aggregated view — couldn't easily\nanswer \"what's queued to publish today?\".\n\n`/saas/website/scheduled` — table of every draft row with\n`publish_at !== null`. Sorted by soonest-firing first. Each row\nshows:\n\n- Title + kind + locale (deep-link to editor for Reschedule/Cancel)\n- Publish timestamp (browser locale)\n- Fires-in countdown (`in 3h 12m` / `5m overdue`)\n\nOverdue rows (publish_at past now) get a warning left-edge accent.\nUsually transient — the 60s cron tick clears them; persistent\noverdue means the publish action failed and the row stays queued\nfor the next retry (Phase 2's per-row try/catch keeps the batch\nworking).\n\nCard header shows `N queued` + `N overdue` chips. Query refetches\nevery 60s so the list visibly drains as the cron fires. No new\nbackend action — client-side filter on `website.page.list({\nstatus: 'draft' })`. Adequate at marketing-site scale; server-side\nfiltering would be the upgrade when an org has hundreds of\nqueued rows.\n\nNew \"Scheduled\" button on the `/saas/website` index nav row.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-scheduled-queue-view.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"196d3532-932f-4165-9de0-cd48811cc00b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-def-preview-pane","type":"changed","scope":"website","summary":"Phase 16.D polish — custom section editor's live preview gains viewport switcher (sm/md/lg), debounce loading indicator, surfaced JSON / Mustache error states.","body":"The custom-section definition editor already calls\n`website.section_def.preview` in a debounced effect — but the\nresult was rendered as a small inline `<div>` with little feedback.\nThis commit makes the preview pane feel like a proper\nlive-rendering surface.\n\nWhat lands in `apps/web/src/routes/saas/website.sections.tsx`:\n\n- **Viewport switcher** (`sm` 375 / `md` 768 / `lg` full). Mirrors\n  the page-editor preview pane (Phase 16.A.1). Width is applied to\n  an inner wrapper so the preview re-flows inline at the picked\n  width. Choice persists per-session via `localStorage`.\n- **Loading indicator** — a pulsing dot + \"rendering…\" label next\n  to the \"Live preview\" heading while the 600 ms debounce is in\n  flight. Operators stop wondering \"did my keystroke register?\"\n- **Error surfacing** — sample-data JSON parse failure no longer\n  silently swallows; renders as a `border-danger/30 bg-danger/.05`\n  pill below the preview. Action-side preview failures\n  (template-compile errors, etc.) also surface here.\n- **Missing-variables warning** now a token-styled\n  `border-warning/30 bg-warning/.06` pill with a count + the\n  variable list in a monospace span, instead of plain warning\n  text.\n- Preview content wraps in a `bg-bg` inner card so the surface\n  contrast matches what the marketing renderer would actually\n  produce (the outer `surface` background was confusing operators\n  who tested templates with light/dark fg colors).\n\nPure UI; no schema, no action changes; 253/253 module tests\nstill green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-section-def-preview-pane.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f07e5e89-f455-4893-8458-24b1a0f24302","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-drag-drop","type":"changed","scope":"website","summary":"Phase 16.A.2 — section cards in the page editor are draggable. Reorder by dragging the card; Up/Down arrows still work for keyboard users.","body":"The Phase 16.A.2 plan called for `@dnd-kit/sortable` but that dep\nwas blocked by the user's mid-flight `pnpm-lock.yaml` edit. This\ncommit ships the equivalent UX using only the HTML5 native\ndrag-and-drop API — no new dependency.\n\nWhat lands in `apps/web/src/components/website/sections-editor.tsx`:\n\n- **Draggable cards** — every non-locked, non-disabled card has\n  `draggable={true}` + handler set on the outer div.\n- **Visual feedback** —\n  - Source card fades to `opacity-50` while being dragged.\n  - Hover target gets a primary-colored `ring-2` outline.\n  - When dragging upward (source > target), a thin primary bar\n    on the top edge of the target shows the insertion point.\n  - Header row picks up `cursor-grab` (active: `cursor-grabbing`)\n    to telegraph the affordance.\n- **Locked sections** (from Phase 16.C templates) are NOT draggable\n  AND reject drops onto themselves — `onDragStart` short-circuits\n  via `e.preventDefault()`.\n- **State tracking** — two component-state slots: `dragSourceIdx`\n  (which card is moving) and `dragOverIdx` (current hover target).\n  Cleared on `onDragEnd` and `onDragLeave` respectively, with\n  `relatedTarget` guarding so leaving a child element doesn't\n  spuriously clear the indicator.\n- **moveTo(from, to)** — splice logic that adjusts the target\n  index when the source comes from a lower position. Also\n  re-maps `expandedIndices` so the operator's expand/collapse\n  state follows the moved card.\n\nKeyboard accessibility unchanged: the Up/Down arrow buttons\nremain the canonical a11y path. The drag affordance is a mouse-\nfirst enhancement — operators who can't use a pointer keep their\nexisting tooling.\n\nWhy not `@dnd-kit`: the apps/web/package.json is currently dirty\nwith the user's `seed:branding` script entry, so adding a dep\nwould bundle their work into mine. Native HTML5 covers the\ndesktop case adequately; if/when the lockfile settles we can\nrevisit with @dnd-kit for richer touch/keyboard ergonomics.\n\nPure UI; no schema, no actions, no test changes (the existing\nsection move logic is exercised by `moveTo`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-section-drag-drop.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b729de73-e36a-4ce3-a4b5-56f6c596b2e2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-sidebar-nav-exposure","type":"changed","scope":"website","summary":"Phase 15.B — sidebar nav now lists every website sub-route (Pending review / Scheduled / Translations / Redirects / Collisions / Archived) so operators don't have to know URLs.","body":"The gap audit flagged 6 website sub-routes as discoverable only\nvia buttons on the `/saas/website` index page — operators who\nlanded at the SaaS sidebar had to know the URLs. Phase 15.B\nexposes everything in the \"Marketing site\" group in\n`apps/web/src/components/modules.tsx`.\n\nNew entries (in the order they sit in the sidebar):\n\n- **Pages** (existing)\n- **Pending review** — Phase 4 + 14.D approval queue\n- **Scheduled** — Phase 14.F scheduled-publish queue\n- **Translations** — Phase 14.E (kind, slug) × language matrix\n- **Redirects** — Phase 1 redirect CRUD\n- **Collisions** — Phase 9 + 14.B slug-collision audit\n- **Archived** — Phase 8 soft-delete recovery\n- **Media** (existing) — Phase 10 lifecycle filter\n- **Settings** (existing)\n\nAll gated by `platform:website:*` (root-only by default), matching\nthe existing perm contract for the group. No backend change.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-sidebar-nav-exposure.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"521f1b1b-bb4a-4245-8c79-5364fda1ef1b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-slash-menu-block-insert","type":"changed","scope":"website","summary":"Phase 16.A.4 — pressing / in the page editor opens a searchable block-insertion palette grouped by Layout / Content / Grid / Visual.","body":"Replaces the inline picker dropdown (\"Pick a block type\" panel) with\nthe existing `<CommandPalette>` (cmdk under the hood). Operators get\nkeyboard-first block insertion identical to Linear / Notion / docs.\n\nBindings:\n\n- **`/`** when no input is focused → opens the palette.\n- **`Cmd/Ctrl + /`** anywhere → opens the palette.\n- **\"Add section\"** button → still works for mouse-first operators;\n  shows the `/` hint inline.\n\nBlocks are grouped:\n\n- **Layout** — hero, eyebrow, cta_footer\n- **Content** — prose, faq, testimonial, stat\n- **Grid** — feature_grid, module_tiles, pricing_cards, comparison_table\n- **Visual** — diagram_flow, trust_strip, logo_strip\n\nEach block gets a short description in the palette (`\"Quote +\nattribution\"`, `\"2/3/4-col feature tiles\"`, etc.) and keywords for\nfuzzy search (typing \"kicker\" finds eyebrow; \"metric\" finds stat).\n\nWhen the tenant has an `allowedBlockTypes` whitelist, the palette\nhonors it — only listed types appear, and a footer note explains how\nmany types are hidden.\n\nA newly-inserted block is auto-expanded so the operator can edit\nimmediately without a second click (mirrors the duplicate-section\nbehaviour from 16.A.5).\n\nNo data changes. Pure UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-slash-menu-block-insert.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"436627cf-0769-4742-83cb-3c7e5b57f927","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-tag-usage-hook","type":"fixed","scope":"website","summary":"Phase 16.E polish — tag usage_count now stays in sync with the page lifecycle (create / update / publish / archive / batch). Popularity sort + zero-usage delete guard finally work.","body":"The blog foundation (`6915d5c6`) shipped `website_blog_tags` with a\ndenormalized `usage_count` column but no maintenance hook — so the\ncolumn stayed at zero forever, the popular-tags sort was meaningless,\nand the \"tag is deletable when usage = 0\" guard incorrectly allowed\ndeletion of every tag.\n\nThis commit adds the hook. New helper at\n`modules/website/src/lib/tag-usage.ts`:\n\n- `applyTagUsageDelta({ db, orgId, prev, next })` — diffs old vs new\n  tag-id arrays + the row's \"counts toward usage\" status (published\n  blog + non-deleted) and applies per-tag +1/-1 deltas. Fast-path\n  no-op when no diff. Decrement uses `GREATEST(x - 1, 0)` so drifted\n  counts don't go negative.\n- `extractBlogTagIds(meta)` — defensive jsonb reader.\n- `pageCountsTowardTagUsage(row)` — the canonical predicate.\n\nWired into five page-action call sites:\n\n1. **`website.page.create`** — increments after the row lands\n   (publishImmediately or draft, but the predicate handles which\n   actually counts).\n2. **`website.page.update`** — diffs `meta.tagIds` between prev and\n   next, applies the delta. Most edits won't change tags → no-op.\n3. **`website.page.publish`** — `false → true` transition flips the\n   counts for every tag on the row.\n4. **`website.page.archive`** — `true → false` transition decrements.\n5. **`website.page.batch_publish` / `batch_archive` /\n   `batch_set_status`** — per-row delta inside each batch loop.\n\n8 new unit tests for the helper itself (predicates + no-op fast\npath + per-direction delta). 249/249 module tests green.\n\nRestore (`website.page.restore` clears `deleted_at` but lands in\n`draft` status) doesn't increment because drafts don't count\ntoward usage — the next publish will flip the counts.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-tag-usage-hook.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7396316e-ff6d-4692-bb0c-c73e4e940391","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-templates-foundation","type":"added","scope":"website","summary":"Phase 16.C — page templates foundation. Migration, schema, 7 actions, locked-section-indices on website_pages.","body":"First half of Phase 16.C of WEBSITE_CMS_V2_PLAN. Backend foundation\nfor \"New page → pick a starter template\" + \"save existing page as\ntemplate.\" Admin UI follows in the next commit.\n\nWhat lands:\n\n- **Migration `0208_0209_website_templates.sql`**:\n  - `website_templates` table (per-org, slug-unique, kind-indexed,\n    starter-flagged partial index for the gallery)\n  - `website_template_status` enum (draft / published / archived)\n  - `website_pages.locked_section_indices integer[]` column —\n    template-pinned positions the editor will guard\n- **Drizzle schema** adds `websiteTemplates`, exposes the new\n  `lockedSectionIndices` column on `websitePages`.\n- **Zod schemas** in `modules/website/src/schemas/templates.ts`.\n- **1 new permission** `platform:website:template:manage` — single\n  CRUD gate; `apply` (clone into a new page) reuses the page\n  module's `:page:create` permission since it's a page creation.\n- **2 policies** in `modules/website/src/policies/template.ts`:\n  managePolicy (CRUD) + applyPolicy (apply only).\n- **7 new actions** in `modules/website/src/actions/template.ts`:\n  - `website.template.create` — new template (draft).\n  - `website.template.create_from_page` — clone an existing page's\n    sections + meta + tags into a new template.\n  - `website.template.update` — content edit with optimistic\n    locking; accepts `status` so publish flows through this verb.\n  - `website.template.archive` — soft-delete; pages already created\n    from the template are unaffected.\n  - `website.template.list` — kind / status / starter filters.\n  - `website.template.get` — by id.\n  - `website.template.apply` — clone template sections + meta into\n    a new page row. The new page is independent (no live link back\n    to the template). Honors template's `lockedSectionIndices`,\n    propagates tags from `metaDefaults.tags` into the page's tag\n    column.\n- **13 new tests**; 194/194 module tests green.\n\nTemplates are starters not bindings: editing a template afterwards\ndoes not retroactively change pages created from it. Globals\n(Phase 16.B) cover the live-link use case; templates cover the\n\"give me a starting point\" use case.\n\nThe locked-section-indices on pages is wired at the data layer but\nthe editor's lock-icon + bulk-op-skip behaviour is the next\ncommit's UI piece, alongside the templates gallery dialog.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-templates-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"68c993e5-d3d8-48c1-929f-8e0e12e9526f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-templates-ui","type":"added","scope":"website","summary":"Phase 16.C (UI) — /saas/website/templates admin, \"Start from a template\" gallery on New page, locked-section UX, sidebar entry.","body":"Completes Phase 16.C. The foundation (previous commit) shipped 7\nactions + the `locked_section_indices` column; this commit wires\nthem through to operators.\n\nWhat lands:\n\n- **`/saas/website/templates`** — list with kind / status filters,\n  Create + Edit dialogs (sections + meta JSON, lock-indices,\n  isStarter, status), Archive button + ConfirmDialog.\n- **\"Start from a template\" banner** on `/saas/website/new`:\n  - Shows every published template matching the selected kind,\n    starters first.\n  - Clicking opens an apply dialog asking only for slug + title +\n    description + language → calls `website.template.apply` →\n    redirects to the editor for the new page.\n  - The existing blank-page form continues to work below for\n    operators who want to start from scratch.\n- **Lock-icon UX** in `<SectionsEditor>`: when the page row has\n  `lockedSectionIndices`, those cards show a small lock icon next to\n  the type badge and Move-up / Move-down / Duplicate / Remove are\n  disabled with helpful titles (\"Locked by template — cannot\n  delete\"). Content fields stay editable.\n- **Editor route** forwards `page.lockedSectionIndices` to\n  `<SectionsEditor>` and exposes the field in the TypeScript\n  `PageFull` interface.\n- **Page serializer** now emits `lockedSectionIndices` on every\n  `website.page.get` response; `PageFull` schema gains the field.\n- **Sidebar entry** \"Templates\" under the Marketing site group.\n\nManage-vs-apply gate works as intended: managing templates needs\n`platform:website:template:manage` (root-only); applying a\ntemplate to create a page needs `:page:create` (already in the\nrelevant blueprints).\n\n194/194 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-templates-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6e5e1df4-e71c-43b5-a1c2-64dfbe151487","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-translation-matrix-and-locale-helper","type":"added","scope":"website","summary":"Phase 14.E — translation status matrix view at /saas/website/translations + locale forwarding in loadCmsOrFallback.","body":"Phase 3 shipped i18n schema + translate action but operators had\nno way to see which pages were translated into which locales. And\nthe Astro `loadCmsOrFallback` helper didn't accept a `language`\nparam at all — locale-prefixed routes (`/es/...`) had no way to\nask the CMS for the right localised row.\n\nPhase 14.E closes both:\n\n**Translation matrix** at `/saas/website/translations`. Pivots\nevery non-archived page into a `(kind, slug) × language` grid.\nCells render the row's status (draft / pending review / published)\nwith the same colour key as the page list; empty cells (`—`) show\nwhere translation coverage is missing. Each populated cell deep-\nlinks to that locale's page editor. Filter by kind + free-text\nslug/title search. New \"Translations\" button on the\n`/saas/website` index nav row.\n\nPivots happen entirely client-side from `website.page.list`\noutput — no new action. Adequate for marketing-site scale (a few\ndozen pages × handful of locales); server-side pivoting would be\nthe upgrade if an org grows past a few hundred rows.\n\n**`loadCmsOrFallback({ language? })`** added — Astro routes under\na locale prefix can now pass `language: 'es'` so the renderer\npicks the localised row. Falls back to the `'en'` row when the\nrequested locale has no published page (Phase 3 fallback\ncontract). Backwards-compatible — existing callers (default `en`)\nunchanged.\n\nNote: this lands the helper plumbing; individual `.astro` route\nfiles don't yet pass `language`. Per-route wiring is parallel\nwork that would touch every page under `apps/marketing/src/pages/`\nand was kept out of this commit to avoid bundling unrelated edits.\nNext batch: extract locale from route prefix in 4 dynamic routes\n(`blog/[...slug]`, `product/[slug]`, `integrations/[slug]`, etc.).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-translation-matrix-and-locale-helper.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"003dbcea-a0ea-4a28-909b-5d37d0823bfb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-ux-polish-bundle","type":"changed","scope":"website","summary":"Phase 15.A — UX polish: schedule TZ hint, search Esc + recent searches, relative-time conflict banner, bulk-action drill-down.","body":"Four Tier-3 UX rough edges from the gap audit closed in one batch:\n\n**Schedule dialog timezone clarity.** The `datetime-local` input\ncaptures wall-clock; the server stores UTC. Operators were\nsilently misreading \"9:00 AM\" — is that their local 9 or UTC 9?\nNow the helper text under the picker shows the operator's tz\n(via `Intl.DateTimeFormat().resolvedOptions().timeZone`) AND the\nexact ISO string the server will store as they pick a time.\n\n**Search bar — Esc to clear + recent searches.** Hitting Escape\nin the search box now clears it (browser default doesn't fire\ninside `<Input>`). The last 8 queries persist to localStorage\nkeyed on `website.page.search.recent` — chip strip below the\nsearch bar surfaces them when the input is empty so operators\ncan re-run a query after a refresh. Debounced 1s so we record\nthe settled query, not every intermediate. \"clear\" link nukes\nthe list. Tolerant of private-mode (catch quota errors).\n\n**Conflict banner — relative-time + clearer copy.** Phase 7's\nbanner just dumped the raw error message (\"Page was edited at\n…\"). Now we regex the ISO out of the message and render\n\"Someone else edited this page 30s ago\" with the absolute\ntimestamp + action explainer below. Falls back to the raw\nmessage when the regex misses.\n\n**Bulk-action drill-down.** Phase 5 toasts said \"12 of 14;\n2 skipped\" with no way to see which 2 failed + why. Now a\nwarning-tone banner above the table lists every failed pageId\nwith its title (joined from the current list query) + the typed\nerror code + message (\"page is pending review; needs\n`:approve`\"). Dismissed with ×; all-succeeded batches still\nclear the banner (and emit a success toast).\n\nNo backend change. Web typecheck clean.\n\nRemaining Phase 15 items: sidebar nav exposure (15.B), per-route\nlocale wiring (15.C), redirect-cache invalidation subscriber\n(15.D).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-01-website-ux-polish-bundle.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9b482354-4e7d-4966-b153-d5e30fda6d0a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-82-ai-prefill-listener","type":"fixed","scope":"chat","summary":"Round 82 — composer now listens for `helios:chat:ai-prefill` window events. Round 72's Try-again button and round 78's suggested-follow-up chips were dispatching the event into the void; the loop is closed.","body":"Two prior rounds shipped the dispatch side of a custom event but neither registered a listener:\n\n- **Round 72** (`fc5d375c`) — \"Try again\" chip under AI responses dispatched `helios:chat:ai-prefill` with a retry preface.\n- **Round 78** (`9f81eeb9`) — Suggested-follow-up chips dispatched the same event with the suggestion text.\n\nBoth chip clicks were silent no-ops in the real UX. Reported in both changelogs as deferred; this round closes the loop.\n\n### Listener\n\n```ts\nuseEffect(() => {\n  if (!editor) return;\n  function handler(e: Event) {\n    const detail = (e as CustomEvent<{ text?: string }>).detail;\n    const text = detail?.text?.trim();\n    if (!text || !editor) return;\n    editor.commands.focus('end');\n    editor.commands.insertContent(`${text} `);\n  }\n  window.addEventListener('helios:chat:ai-prefill', handler as EventListener);\n  return () => window.removeEventListener('helios:chat:ai-prefill', handler as EventListener);\n}, [editor]);\n```\n\n### Design notes\n\n- **Inserts at the current caret** with `editor.commands.insertContent` rather than replacing the doc — power users who've already started typing keep their draft; the suggestion text appends. Cursor lands past the trailing space so they can continue typing immediately.\n- **Trailing space** after the inserted text → the user types directly without having to add a separator manually.\n- **Focus first** (`editor.commands.focus('end')`) so the keyboard moves to the composer before the insert. Without this, the click stays on the AI thread chip and the user has to click into the composer.\n- **`[editor]` dependency** → re-binds whenever the editor instance remounts (channel switch in the AI pane).\n- **No channelId guard** — both dispatchers (the AI chips) only render inside the AI thread pane, which is bound to a single channelId context. The composer that catches the event is the one rendered inside that same pane.\n\n### What the event flow looks like end-to-end now\n\n1. User opens Ask Helios → `<AiThreadPane>` mounts inside `channel-view.tsx`.\n2. AI replies; `<AiFollowupChips>` renders three suggestion chips.\n3. User clicks \"Summarize the key points\" chip.\n4. Chip dispatches `new CustomEvent('helios:chat:ai-prefill', { detail: { text: 'Summarize the key points' } })`.\n5. The `<Composer>` rendered inside the same `<AiThreadPane>` catches the event, focuses, and inserts `\"Summarize the key points \"`.\n6. User can tap Send immediately, or edit first.\n\nSame flow for round 72's Try-again chip — same prefill mechanism, different source text.\n\n**Verification:** chat 107/107 tests pass; composer-tiptap typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.353Z","updatedAt":"2026-06-05T01:29:12.353Z"},{"id":"bf457854-d776-4db3-82ed-31237f494e5f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"fix-marketing-pricing-typecheck","type":"fixed","scope":"marketing","summary":"Unblocked the marketing CI workflow by fixing Plan slug type mismatch in pricing.astro.","body":"`apps/marketing/src/pages/pricing.astro` imports `plans.json` and passes the same array to both `PricingPageTemplate` (slug typed as `string`) and `WebsitePageRenderer` (slug typed as `'free' | 'starter' | 'business' | 'enterprise'`). The previous cast typed `plans` to PricingPageTemplate's wider shape, so the assignment to WebsitePageRenderer's narrower union failed `astro check` with `ts(2322): Type 'Plan[]' is not assignable to ...`.\n\nFix: cast `plansData` to the renderer's narrower shape instead. The bundled data has exactly the 4 standard slugs and only `'month'/'year'/'none'` billing intervals, so the narrower types are accurate. PricingPageTemplate's wider type still accepts the narrower one (structural assignability).\n\nThis was the single blocking error keeping the `marketing` GitHub Actions workflow red on every push since the rebrand started 2026-06-01 (and likely earlier). The cascade revealed ~29 pre-existing biome lint errors that were hidden behind it (`astro check && biome check src && tsc --noEmit` short-circuits at the first failure) — those are a separate cleanup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-fix-marketing-pricing-typecheck.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5c75b67a-9381-46c6-856c-eb4c8a20bece","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-amber-palette","type":"fixed","scope":"marketing","summary":"The marketing site now renders the Odexy amber brand colour instead of the old purple.","body":"The marketing site's design tokens still carried the previous reference palette\n(purple primary, indigo-tinted neutrals), and nothing injected the brand colour\nat runtime — so the landing page hero accent, every primary CTA, and all the\nglows/halos/focus rings rendered purple, not the Odexy amber. Migrated\n`tokens.css` to the brand palette: amber `#FBA82C` primary with an **Ink**\nforeground (white-on-amber fails contrast — the same fix shipped in-app), and\nde-purpled the body/accent neutrals to Ink-based tones. Updated the `inverse`\nsection variant so links on accent panels stay legible in both themes now that\nthe primary foreground is dark.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-amber-palette.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b3744275-52db-4f94-8c46-6268a6c58b46","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-amber-text-contrast","type":"fixed","scope":"marketing","summary":"Primary-coloured links and accent text on the marketing site now meet contrast as a darker amber.","body":"Bright amber `#FBA82C` only reaches ~1.9:1 on a white surface, so amber links,\neyebrows, and the hero accent word were hard to read in light mode. Added a\ntheme-aware `--color-primary-strong` token (a deeper amber that passes AA on\nlight, lifting back to the bright amber on dark) and applied it to the text\nfunnels — the `Link` primitive, the `Eyebrow` block, and the landing-page\ninline links + hero/cascade accent words. Bright `--color-primary` is retained\nfor fills, dots, glows, and amber-on-dark text.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-amber-text-contrast.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a2776cd2-8a57-4f66-b6fa-a3f6c8a3dd12","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-biome-cascade-cleanup","type":"fixed","scope":"marketing","summary":"Cleared 29 biome lint errors hidden behind the prior astro check failure in the marketing CI workflow.","body":"After the prior `fix-marketing-pricing-typecheck` commit unblocked astro check, the `check` script (`astro check && biome check src && tsc --noEmit`) reached biome for the first time in days and surfaced 29 errors + 43 warnings the `&&` short-circuit had been hiding.\n\nThree sweeps cleared them:\n\n1. **`biome check src --write` (safe auto-fixes)** — 21 files fixed: import-type, unused template literals, etc.\n2. **`biome check src --write --unsafe` (suggested auto-fixes)** — 8 more files fixed: useless switch cases, exponentiation operator, additional template literals.\n3. **Manual fixes for the residual ~8**:\n   - `noArrayIndexKey` in `bento-cells.tsx` (5 sites) — converted unique-data iterations to data-derived keys (`l.ts`, `h`); for synthetic `Array.from` iterations + duplicated-data iterations (`['M','T','W','T','F']`), added `// biome-ignore` directly above the `key={...}` line with an explanation of why the position IS the identity.\n   - `noArrayIndexKey` in `code-tabs.tsx` (2 sites) — same biome-ignore pattern for tokenised spans + code lines (blank lines are legitimately duplicated, line number is the identity).\n   - `noUnusedVariables` in `closing-call.tsx` — wired `brandName` into the section's `aria-label`. The prop was added when the rebrand sweep threaded brand strings down but never consumed; consuming it for the closing CTA's a11y label is the minimal correct fix.\n\nResult: `pnpm --filter @helios/marketing check` exits 0. 14 a11y warnings remain (noSvgWithoutTitle in legacy decorative icons, useSemanticElements in form components) — non-blocking and deferred to a separate a11y sweep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-biome-cascade-cleanup.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"c237c87a-ae67-4171-be9e-74e7230a772a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-content-brand","type":"changed","scope":"marketing","summary":"Rebranded the marketing website copy to Odexy across compare, solutions, blog, integration and legal pages.","body":"The marketing site's prose now reads **Odexy** instead of the old reference\nbrand — across the comparison pages (vs ClickUp / HubSpot / Monday / Notion /\nOdoo / Zoho), the solutions pages, blog posts, integration docs, the changelog\npage, and the legal MDX (privacy / terms / DPA / sub-processors / security /\ncompliance / HIPAA). The legal-entity name \"Helios Works\" maps to \"Odexy\".\n\nDeliberately preserved by the guarded sweep:\n\n- `HeliosClient` — the published SDK class name in `developers.astro` code\n  samples (renaming it would mis-document the real `@helios/client` export).\n- `Helios-Signature` — the webhook signature header shown in the dev docs,\n  which must match the live wire header (a coordinated rename, tracked with the\n  domain migration).\n- The lowercase `heliosworks.com` domain and `@heliosworks.com` addresses —\n  owned by the in-flight domain/email migration, flipped there when the\n  `odexy.app` aliases are live.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-content-brand.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8cf2e1c9-1dca-49a0-a71f-9aff6245a9cc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-presets-editor","type":"added","scope":"website","summary":"Phase 16.I editor integration — \"Save as preset…\" button + slash-menu surfaces published presets under a Presets group. One click drops a preset's sections at the cursor.","body":"The presets foundation (table + CRUD actions, prior commit) had no\nin-editor UI. This commit closes the loop so operators actually\nbenefit:\n\n**Save flow** — toolbar gets a new \"Save as preset…\" button next\nto Outline / Expand all / Collapse all (only shown when 2+\nsections + the editor isn't disabled). Clicking opens a centered\ndialog:\n\n- **Name** (required, autofocused).\n- **Slug** (optional — autoderived from the name if empty;\n  operator can override for nicer URLs in the upcoming admin\n  list).\n- On save: calls `website.preset.create` with the current sections\n  array verbatim; toasts (via the dialog's own inline error\n  surface, not a toast — keeps the operator in flow); refetches\n  the slash-menu's preset list so the new entry appears\n  immediately on the next slash press.\n\n**Insert flow** — `SectionsEditor` fetches\n`website.preset.list({ status: 'published' })` once per editor\nsession (60s `staleTime`, gracefully no-ops to empty on\npermission denial). Published presets join the slash menu's\nexisting 14 code-defined block types under a new \"Presets\" group:\n\n- Label = the preset's `name`.\n- Description = `previewSummary` ?? `description` ??\n  `\"N sections — <slug>\"`.\n- Keywords include `'preset'`, the slug, and the name so cmdk's\n  fuzzy filter finds them quickly.\n- Click → appends the preset's sections to the end of the current\n  array. Honors the per-page `allowedBlockTypes` whitelist: any\n  blocks in the preset whose type isn't in the whitelist are\n  silently dropped. The newly inserted cards auto-expand.\n\nPure UI on top of the existing actions; no schema changes; 267/267\nwebsite tests still green.\n\nWhat's not in this commit (still queued):\n- Multi-select sections + \"Save selection (not entire page) as\n  preset.\" Today the dialog saves every section; operators delete\n  unwanted ones before saving.\n- Admin route `/saas/website/presets` to list / rename / delete\n  from one place.\n- Insert at the cursor position (not just at the end). Lower\n  priority — drag-drop reorders are now fast since 16.A.2.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-presets-editor.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"bea9aad3-b1b8-4fd3-8a2e-82e4e2bbd502","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-copy-brand","type":"fixed","scope":"marketing","summary":"Marketing components + the changelog feed now read the resolved brand name instead of hard-coding the reference brand.","body":"The reusable marketing blocks that named the product — the ROI calculator\n(\"With Odexy\", recommended-plan line, cost-vs-today bar), the demo-video poster\n(brand square + wordmark + walkthrough label), the pricing-comparison bar\n(\"Same team on …\"), the tenant triptych (\"powered by …\"), the newsletter\nheadline, and the home-page ROI lede — now derive the name from\n`bundledBrandingDefaults().appName` (the build-time branding snapshot, which\n`fetch-branding` populates), falling back to a neutral word when no brand is\nset. The marketing `/changelog.xml` RSS feed brands its title/description and\nlinks from the same snapshot (brand name + marketing URL) instead of the\nhard-coded old name + domain.\n\nRemaining (a separate content pass, not code): ~376 \"Helios\" mentions in\nmarketing **prose content** (`.astro` compare/solutions pages, `.mdx` blog /\nintegration / legal docs) — these need a deliberate copy rebrand (incl. the\ndeferred `@heliosworks.com` email + domain references).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-copy-brand.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9c783bc9-6eab-4e9d-9251-af9fd73bb388","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-dark-first","type":"changed","scope":"marketing","summary":"The marketing site is now dark-first with a modern cool-Ink palette.","body":"The marketing site now defaults to a dark theme (the modern dev-tool idiom —\nLinear/Vercel/Resend), only following an explicit user toggle rather than the\nOS preference. The dark palette was rebuilt from flat neutral grey to a\ncohesive cool-Ink set keyed to the Odexy Ink hue, with a few-percent surface\nelevation and low-contrast borders, so the amber accent reads vivid against it.\nLight mode remains available via the nav toggle.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-dark-first.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"03fda69d-221c-48f9-8794-3cc883780615","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-hero-minimal","type":"changed","scope":"marketing","summary":"Redesigned the homepage hero to a sleek, dark-first layout with a single product window.","body":"The homepage hero moved from the dense 8-cell Mondrian bento to a sleek\nminimal layout in the modern dev-tool idiom (Linear/Vercel): an announcement\npill, an oversized centred headline with one amber accent word, a constrained\nsubhead, the CTA row + trust strip, and then a single calm product window that\nshows a CRM deal advancing into the audit log — the whole pitch in one frame.\nGenerous negative space, hairline borders, restrained CSS-only motion. The\nprevious bento hero is retained as `HeroBento` for easy revert.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-marketing-hero-minimal.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"47caafe6-8f2d-4134-bb1a-87878ac021d0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"unify-app-accent","type":"changed","scope":"web","summary":"Unified the app chrome to the single primary brand colour instead of per-module accents.","body":"The rail, module sidebar, topbar, command palette and PWA titlebar now tint\ntheir active items with the one resolved brand primary (`--accent`) rather than\na per-module hue, so the whole app chrome reads in a single, consistent brand\ncolour. `neutral` affordances stay neutral. (To restore per-module hues, point\nthe `accentVar` keys back at their `--color-module-*` tokens.)\n\nAlso removed the short-lived dark sidebar/rail experiment — the app themes\nglobally via a single `data-theme` on `<html>`, so a per-subtree dark override\nrendered only partially in light mode. The chrome follows the app theme again.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-unify-app-accent.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9334d16e-8fa7-495f-9f3c-95d29712972d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-17a-final-fixes","type":"fixed","scope":"website","summary":"Phase 17.A.7/8 — request_review clears any pending publishAt schedule; /es/index.astro now reads branding + drops the codename literal.","body":"Closes the last two Tier 1 items in Phase 17.A.\n\n**17.A.7 — scheduled-publish + pending-review orphan.**\nThe data audit found: if a draft had `publishAt` set and the\noperator hit \"Request review,\" the row flipped to\n`pending_review` but the scheduled-publish cron requires\n`status='draft'`, so the schedule was silently orphaned forever —\neven after approval the cron would never fire.\n\nFix: `requestReviewPage` now clears `publishAt` as part of the\nstatus flip + logs the original schedule + appends a \"Scheduled\npublish cleared\" line to the revision note. Approve-publish\ntypically happens immediately, not at the old schedule's time;\noperators can re-schedule post-approval if they need a future\ndate.\n\n**17.A.8 — /es/index.astro brand leak + missing branding.**\nThe renderer audit found the Spanish home (a) hardcoded the\ncodename in body copy (\"Odexy es un solo esquema.\") and (b)\nomitted `branding={...}` props to TopNav + Footer, so a\nwhite-label deployment got the wrong wordmark + footer.\n\nFix:\n- Pulls `branding` via `loadBranding({ request, kv })`.\n- Passes `branding={branding}` to both `<TopNav>` + `<Footer>`.\n- Replaces the codename literal with `{appName}` (defaults to\n  \"el Work OS\" when branding has no appName configured —\n  neutral fallback per the no-static-branding rule).\n- Builds canonical URL from `branding.marketingUrl` instead of\n  hardcoding `https://heliosworks.com`.\n\n280 module tests + 46 marketing tests still green.\n\nPhase 17.A wraps up with 8 fixes shipped:\n- 17.A.1 transactional snapshots\n- 17.A.2 preview cross-org leak (security)\n- 17.A.3 restore_revision whitelist enforcement\n- 17.A.4 pricing_cards renderer fix\n- 17.A.5 /product accent ternary\n- 17.A.6 /preview prefetches globals + section-defs + plans + 404\n- 17.A.7 request_review clears pending schedule\n- 17.A.8 /es brand leak + branding props","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-17a-final-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"506d20d4-a57e-43d8-91e1-18536f283e98","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-17b-dirty-form-and-approve-concurrency","type":"fixed","scope":"website","summary":"Phase 17.B.1/2 — page editor surfaces \"Unsaved changes\" banner + blocks tab close via beforeunload; pending queue's inline approve / reject now pass expectedUpdatedAt.","body":"Two Tier 1 UX-audit findings closed:\n\n**17.B.1 — dirty-form guard.**\nThe editor used to silently drop unsaved edits if the operator\nnavigated away, refreshed, or hit a `queryClient.invalidateQueries`\nafter a save (which re-seeded the form state from the freshly-\nfetched row). The UX audit flagged this as Tier 1 (\"work loss\").\n\nFix in `apps/web/src/routes/saas/website.$id.tsx`:\n- Captures the post-load form state as `loadedSnapshot` (JSON\n  string of every editable field). Re-captures after every\n  successful save so the dirty marker resets correctly.\n- `isDirty` is a `useMemo` comparing current form state against\n  the loaded snapshot. Cheap (one JSON.stringify per render);\n  triggers downstream UI without flicker.\n- **Beforeunload guard** — adds a `beforeunload` listener that\n  calls `preventDefault()` + sets `returnValue=''` when dirty.\n  Browser shows its standard \"Changes you made may not be\n  saved\" dialog before tab close / refresh / address-bar\n  navigation.\n- **Sticky \"Unsaved changes\" banner** — warning-tinted card\n  with a \"Save now\" button + Cmd/Ctrl+S + Cmd/Ctrl+Z hints.\n  Only shown when `isDirty && canUpdate && !conflictBanner` so\n  the existing conflict-handling banner doesn't compete.\n\n**17.B.2 — inline approve / reject concurrency.**\nThe `/saas/website/pending` queue's `Approve` button called\n`website.page.publish` with no `expectedUpdatedAt`. An approver\nclicking 30s after the author committed a critical edit would\nlast-write-wins publish the stale-from-their-view content. The\neditor route handles this guard correctly (Phase 7); the queue\ninherited none of it.\n\nFix in `apps/web/src/routes/saas/website.pending.tsx`:\n- `approveMutation` now takes `{ pageId, expectedUpdatedAt }`.\n  The button passes `p.updatedAt` from the row the approver\n  clicked.\n- `rejectMutation` does the same via the expanded `rejectDialog`\n  state.\n- `onError` catches `ActionCallError` with `code === 'conflict'`\n  + surfaces an actionable message (\"Page was edited since you\n  opened this queue. Refresh + re-check…\") + auto-invalidates\n  the list so the next click carries the fresh updatedAt.\n\n280 module + 46 marketing tests still green.\n\nComing next in Phase 17.B:\n- URL-persist list filters per `.claude/rules/tanstack.md`.\n- Unified sub-nav across every `/saas/website/*` route.\n- Revision diff view.\n- Mobile editor (collapse preview on narrow viewports).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-17b-dirty-form-and-approve-concurrency.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fe207a04-07f7-4fd3-a54f-04b7e05ea81e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-adopt-from-hardcoded","type":"added","scope":"website","summary":"Phase 16.F — operators can adopt any hardcoded marketing page into the CMS with one click. Adopt button on /saas/website/inventory + new action with kind-specific starter content.","body":"The inventory route from the previous commit could only display the\ngap — operators saw \"/about is hardcoded\" but had to manually\ncreate a matching CMS row. This commit closes that loop.\n\n**New action** — `website.page.adopt_from_hardcoded(kind, slug,\nlanguage)`. Steps:\n\n1. Looks up the inventory entry — `not_found` if the `(kind, slug)`\n   pair isn't in `MARKETING_PAGE_INVENTORY`.\n2. Checks for an existing CMS row with the same `(kind, slug,\n   language)` — `conflict` if one exists (operators get pointed at\n   \"edit the existing row instead\" via the error message).\n3. Inserts a draft row with title + description prefilled from the\n   inventory entry + a kind-specific starter sections array (see\n   below) + the org's actor as createdBy/updatedBy.\n4. Emits `website.page.created` so existing subscribers fire.\n\nGated by `platform:website:page:create`. Idempotent at the\nconflict-detection layer — a duplicate adopt errors cleanly\nwithout leaving stale state.\n\n**Kind-specific starters** — hand-coded sensible defaults in\n`starterSections()`:\n\n- `page` — hero + prose body + cta_footer (3 sections).\n- `persona` — eyebrow-style hero + 3-tile feature_grid +\n  cta_footer.\n- `compare` — hero + comparison_table + prose + cta_footer.\n- `integration` / `module` — hero + 3-tile feature_grid +\n  cta_footer.\n\nThe starter content is intentionally generic (\"Add detailed\ncomparison narrative here\") so operators iterate immediately\nwithout inheriting placeholder copy that ships to production.\n\n**UI** — the \"Adopt (coming)\" placeholder on /saas/website/inventory\nis now a primary-tinted \"Adopt →\" button that fires the mutation,\ntoasts on success, invalidates the inventory query, and navigates\nthe operator into the new page's editor (`/saas/website/$id`).\nDisabled with a clear tooltip when the actor lacks\n`platform:website:page:create`.\n\n**Tests** — 6 new cases covering the 4 lifecycle branches\n(not_found / conflict / ok / policy_denied) + list_inventory's\nabsent vs in-CMS classification. 259/259 module tests green.\n\nCombined with the inventory route, this is the answer to \"pages\nand sections that we have should be manageable\" — every\noperator-editable route on the marketing site is now (a) visible\nin the admin and (b) one click away from being CMS-managed.\n\nComing next:\n- Section content extraction — today's starter is a generic\n  3-section default. A `website.page.adopt_from_hardcoded` mode\n  that scrapes the live page's headings + paragraphs (via Astro's\n  build manifest, ideally) to seed real content automatically.\n- Bulk adopt — select N rows in the inventory, adopt them all.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-adopt-from-hardcoded.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9dce0f60-76c0-4658-afa7-05ae878b594b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-adopt-live-scrape","type":"changed","scope":"website","summary":"Phase 16.F+ — adopt-from-hardcoded now scrapes the live page's headings + lead paragraph and seeds them into the starter sections. Operators inherit real content instead of generic placeholders.","body":"The adopt-from-hardcoded action used to seed every adopted page\nwith a generic 3-section starter (\"Write the body copy here.\",\n\"Ready when you are.\", \"Replace X / Cut Y / Ship Z faster\"). When\noperators adopted /about or /careers — pages that already had\nreal production copy — they immediately rewrote the starter, which\nfelt redundant.\n\nThis commit makes the adoption smart: the action best-effort-fetches\nthe operator's live marketing page and seeds the starter with the\nreal headings + lead paragraph it finds.\n\n**New helper** —\n`modules/website/src/lib/scrape-live-page.ts`:\n\n- `scrapeLiveContent(url)` — HTTP GET with 3s AbortController\n  timeout, 2xx + text/html guards, identifies via a clear\n  `HeliosCmsAdopter/1.0` user-agent. Returns null on any failure\n  so the adopt action falls through cleanly to the generic\n  starter.\n- `parseScrapedContent(html)` — pure extractor (separated so unit\n  tests stay deterministic):\n  - Scopes to `<main>` when present (skips header h1s like the\n    wordmark + footer h2s like \"Footer column\"). Falls back to\n    the whole document when no main.\n  - Pulls the first `<h1>` text (strip-tags + entity-decode).\n  - Pulls up to 5 `<h2>` texts in document order, dropping\n    empties + over-200-char items as obviously-not-section-titles.\n  - Pulls the first `<p>` with at least 80 chars of plain-text\n    (skips short nav/footer copy).\n  - Tiny entity decoder handles `&amp; &lt; &gt; &quot; &#39;\n    &#x27; &nbsp; &apos;` + numeric `&#NNN;` forms.\n\nDeliberately no `cheerio` / `jsdom` dep — regex is sufficient\nbecause Astro marketing layouts are well-known shapes we control.\n\n**Adopt action wired** —\n`modules/website/src/actions/inventory.ts:starterSections` now\ntakes an optional `scraped` argument; the handler resolves\n`platform_settings.marketingUrl` (falls back to\n`PUBLIC_MARKETING_URL` / `BETTER_AUTH_URL`), composes the full URL\nfrom `entry.path`, and calls `scrapeLiveContent` before the insert.\n\nScraped overrides applied:\n- `hero.heading` ← `scraped.h1 ?? entry.title`\n- `hero.subheading` ← `scraped.leadParagraph ?? entry.description`\n- `prose.markdown` ← `scraped.leadParagraph ?? \"Write the body\n  copy here.\"`\n- `feature_grid.tiles` ← scraped h2s (when at least 3 are found)\n  as titles, otherwise the kind-specific generic placeholders.\n\nThe bulk-adopt flow benefits automatically — the loop calls the\nsame action per row, so 30+ pages adopted in bulk all get their\nreal content seeded if the scrape succeeds.\n\n8 new parser unit tests cover empty input, scoped-to-main, fallback\nto document, inner-tag stripping, entity decoding, short-paragraph\nskip, the h2 cap, and the empty/oversize filter. 275/275 module\ntests green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-adopt-live-scrape.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"cd0b830c-f2ef-49a0-88f0-307f6dcaaec6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-bulk-adopt","type":"added","scope":"website","summary":"Phase 16.H — bulk-adopt hardcoded routes into the CMS. Tick the rows you want + click \"Adopt N\" — sticky action bar handles the loop with a progress indicator.","body":"Hardcoded → CMS adoption was one-at-a-time. With 30+ pages in the\ninventory and the typical operator wanting \"all of them in the\nCMS so my team can edit,\" that's a lot of clicking. This commit\nadds bulk adopt.\n\nWhat lands in `apps/web/src/routes/saas/website.inventory.tsx`:\n\n- **Row checkboxes** — each hardcoded (`cmsStatus === 'absent'`)\n  row gets a checkbox in a new first column. CMS-managed rows\n  render a blank slot in its place (no checkbox — they don't need\n  re-adopting). Column hidden entirely for actors without\n  `platform:website:page:create`.\n- **\"Select all hardcoded\" button** in the page header — one-click\n  selects every absent row across all categories. Disabled when\n  no adoptable routes exist or a bulk run is in flight.\n- **Sticky bottom action bar** — fixed to viewport bottom when 1+\n  rows are selected. Shows the selected count + Clear + \"Adopt N\"\n  buttons; styled with the same primary-tinted shadow language as\n  the other floating affordances.\n- **Serial loop with per-row outcome tracking** — `runBulkAdopt`\n  iterates the selected set in insertion order. Each iteration:\n  - Calls `website.page.adopt_from_hardcoded`.\n  - Tallies the outcome into `ok` / `conflict` / `failed` counts.\n  - Conflicts (row already in CMS) and failures don't abort the\n    loop — the remaining selections still get tried.\n  - After each call, updates `bulkProgress` so the action bar's\n    progress bar fills smoothly.\n- **Summary toast** — at the end, one toast: \"5 adopted · 2 already\n  in CMS · 1 failed.\" (Sections omitted when zero.) Error toast\n  if any failures; success toast otherwise. The inventory query\n  invalidates so the row statuses re-render immediately.\n\nThe action layer's idempotency (conflict on existing\n(kind, slug, language)) means a bulk run is recoverable — running\nit again is safe and will only adopt the rows that weren't covered\nthe first time.\n\nWhat stays single-click — the per-row \"Adopt →\" button. Operators\nwho want to adopt one specific row and jump into its editor still\nhave that path (the bulk loop intentionally doesn't navigate\nanywhere; it stays on the inventory page so operators can verify\nthe outcome).\n\nPure UI on top of the existing action; no schema, no new actions,\nno test changes; 259/259 website tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-bulk-adopt.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"052a7e03-07f7-4068-ab3b-f7f44f36ec54","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-bulk-multi-select-actions","type":"changed","scope":"website","summary":"Phase 16.K.9 — the per-card selection now drives bulk Move ↑ / Move ↓ / Delete N in addition to Save as preset. Locked cards are tick-blocked.","body":"The Phase 16.I.2 multi-select checkboxes only fed save-as-preset.\nOnce an operator had a cluster ticked, they still had to delete /\nmove each section one-by-one. This commit lets the same selection\ndrive every bulk operation.\n\nNew toolbar cluster (appears only when 1+ cards are ticked):\n\n- **Clear (N)** — already existed.\n- **↑ N** — moves every selected card up by one slot, preserving\n  contiguity. Skips when the top of the selection is already at\n  index 0.\n- **↓ N** — mirrors, downward.\n- **Delete N** — danger-tinted; removes every selected card,\n  clears the selection + the expanded-cards state (which would\n  otherwise be stale after the splice).\n\n`bulkMoveSelected(direction)` builds a single splice that moves\nthe selected items as a **contiguous block** at the new anchor\nposition — non-contiguous selections clump together when moved.\nThat matches operator intent: \"move these together\" not \"shift\neach by 1 independently.\"\n\n`bulkDeleteSelected` rebuilds the array filtered to non-selected\nindices in one pass; reverse-iteration avoids index-drift bugs.\n\n**Lock-aware**:\n- The per-card checkbox is now `disabled` (not just hidden) when\n  the card is template-locked. Operators see the column but\n  can't tick locked items.\n- The bulk action handlers defensively skip any locked indices\n  that somehow ended up in the selection — operators see fewer\n  items move/delete than they ticked if they bypassed the\n  disabled checkbox via DevTools, etc.\n\nPure UI on existing state; no schema, no actions, no test\nchanges; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-bulk-multi-select-actions.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e05cddf6-4ebc-426b-ac8b-2ca8f8eef4e1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-card-focus-ring","type":"changed","scope":"website","summary":"Phase 16.K.16 — section cards render an explicit primary ring on focus-visible. Makes J/K nav + Tab cycling obvious.","body":"The Phase 16.K.12 J/K keyboard nav focused cards via `.focus()`,\nbut the resulting outline was the browser default — invisible\nagainst the card's surface tint on some user agents and\ninconsistent across them.\n\nThis commit adds an explicit `focus-visible:ring-2\nring-primary ring-offset-2 ring-offset-bg` to every section\ncard root. Operators pressing J/K or Tab now see an obvious\nprimary-tinted ring on the landed card, mirroring the existing\ndrag-target ring's language (so the editor has one consistent\nfocus visual).\n\nUses `:focus-visible` not `:focus` so mouse clicks don't spam\nthe ring; only keyboard focus shows it.\n\nPure UI; no schema, no actions, no test changes; 280/280 module\ntests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-card-focus-ring.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"79cf6bda-1979-4ee6-98a8-c5baecc69b34","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-card-group-accent","type":"changed","scope":"website","summary":"Phase 16.K.15 — section cards get a left-edge accent stripe colored by their block group (Layout / Content / Grid / Visual). Long pages scan as a column of category hues.","body":"The card stack on a 15+-section page was visually uniform —\nevery card had the same neutral surface. Operators looking for\n\"the FAQ\" or \"the trust strip\" relied on the block-label badge\ninside the header.\n\nThis commit adds a 4px left-edge accent stripe per card colored\nby the section's group (the same group operators see in the\nslash-menu palette):\n\n- **Layout** (hero / cta_footer / eyebrow / global_ref) →\n  `bg-primary/50` (amber on Odexy, indigo on legacy default).\n- **Content** (prose / faq / testimonial / stat) →\n  `bg-info-500/40` (cool blue).\n- **Grid** (feature_grid / module_tiles / comparison_table /\n  pricing_cards) → `bg-warning/40` (warm yellow).\n- **Visual** (diagram_flow / trust_strip / logo_strip) →\n  `bg-success-500/40` (green).\n\nToken-driven, so the stripe follows the operator's brand theme +\ndark mode + high-contrast modes automatically. `overflow-hidden`\nadded to the card root so the stripe clips cleanly against\nrounded corners.\n\nPure UI; no schema, no actions, no test changes; 280/280 module\ntests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-card-group-accent.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e394d2d1-5f75-4bca-90aa-17c88cd36261","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-card-keyboard-nav","type":"changed","scope":"website","summary":"Phase 16.K.12 — vim-style J / K + arrow / Home / End navigation between section cards. Jumps to first/last when no card is focused.","body":"The per-card shortcuts from Phase 16.K.3 (Alt+↑/↓, Cmd+D, Del)\nrequired Tab-cycling to focus a card first. Operators editing\nlong pages spent half their time hunting Tab to land on the\nright card.\n\nThis commit adds proper card-to-card navigation:\n\n- **J / ↓** — focus the next card. With no card focused, jumps\n  to the first card.\n- **K / ↑** — focus the previous card. With no card focused,\n  jumps to the last card.\n- **Home** — jump to the first card.\n- **End** — jump to the last card.\n\nVim-style J/K + standard arrow keys + Home/End — operators with\nany keyboard discipline land. All four paths share the same\nclamped focus helper that calls `el.focus()` + `scrollIntoView`\nwith smooth/nearest so the landing position stays visible.\n\n**Constraints**:\n\n- Inside text inputs the shortcuts don't fire (J in a textarea\n  types \"j\"). Same skip-when-editable check as the existing\n  shortcut block.\n- Any modifier (Alt/Cmd/Ctrl/Shift) disables nav — Alt+↑/↓ stays\n  as \"move card up/down\" (Phase 16.K.3), Cmd+↑/↓ stays as browser\n  scroll.\n- Honors the existing `tabIndex={0}` on cards from Phase 16.K.3;\n  no new DOM plumbing needed.\n\nCheat-sheet (opened via `?`) gains four new entries.\n\nPure UI on existing state; no schema, no actions, no test\nchanges; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-card-keyboard-nav.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f2e5af01-a780-413c-a350-65c1fb06c629","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-card-keyboard-shortcuts","type":"changed","scope":"website","summary":"Phase 16.K.3 — focused section cards respond to Alt+↑/↓ (move), Cmd/Ctrl+D (duplicate), Del/Backspace (remove). Cheat-sheet updated.","body":"The page-builder shortcuts only covered global actions (save,\npreview, slash menu). Per-card operations still required clicking\nthe Up/Down arrow buttons, the Duplicate icon, or the Trash icon.\nFor keyboard-heavy editing flows, that's friction.\n\nThis commit adds card-scoped shortcuts to the existing\ndocument-level keydown listener in `SectionsEditor`:\n\n- **Section cards now `tabIndex={0}`** so they're focusable. Tab\n  through the editor → focus lands on each card in document\n  order.\n- **Alt + ↑ / Alt + ↓** — move the focused card up / down. Honors\n  template locks; no-ops on already-top/already-bottom.\n- **Cmd/Ctrl + D** — duplicate the focused card (mirrors the\n  existing Duplicate icon button). Honors template locks.\n- **Delete / Backspace** — remove the focused card. Fires only\n  when no modifier keys are held (so Cmd+Delete inside a text\n  field — which deletes the previous word — isn't hijacked) AND\n  the focus is on the card chrome, not inside a text input\n  (operators expect Backspace to delete characters in inputs).\n  Honors template locks.\n\nActive-element resolution uses the `data-section-card-idx`\nattribute the cards already carry (added in Phase 16.G for the\noutline rail + in-context-editing click handler), so no\nadditional DOM plumbing is needed.\n\nShortcut cheat-sheet (opened via `?`) updated with the three new\nentries under a brief note explaining the focus-first requirement.\n\nPure UI on existing state + helpers; no schema, no actions, no\ntest changes; 280/280 module tests still green.\n\nComing next:\n- `Cmd+Enter` to save without leaving the section editor.\n- Multi-select via `Shift+Click` then bulk move / delete.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-card-keyboard-shortcuts.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"affe1306-ee13-4551-a1d2-dfd765850e0c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-composition-summary","type":"changed","scope":"website","summary":"Phase 16.K.17 — section editor toolbar shows a composition summary (\"1 hero · 3 feature grids · 1 cta footer\") next to the Outline button.","body":"Pages with 10+ sections were hard to glance-read for \"balance\" —\ndoes this page have too many CTAs? Did I forget the closing\nfooter? Operators had to scroll the full card stack to count.\n\nThis commit adds an inline composition summary pill next to the\nOutline button in the section editor toolbar:\n\n- Counts sections by type via a useMemo over the array.\n- Sorted descending by count (then alpha by type name) so the\n  dominant blocks lead the summary.\n- Pluralized labels — \"1 hero · 3 feature grids · 1 cta footer\".\n- Hidden on narrow viewports (`sm:inline`) so the toolbar stays\n  uncluttered on mobile + small editor windows.\n- Tooltip carries the full summary if the truncate cuts it off.\n\nPure UI on existing state via a memoized derivation; no schema,\nno actions, no test changes; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-composition-summary.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3c8bd1f1-07d9-4203-9032-1dacd2764752","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-presets-foundation","type":"added","scope":"website","summary":"Phase 16.I foundation — section presets (kind-agnostic, reusable bundles of N sections). Migration + schema + 4 CRUD actions; UI integrations land next.","body":"Section presets are kind-agnostic bundles operators reuse across\npages — \"trust strip + 3 stats + cta footer,\" \"diagram_flow + faq,\"\n\"hero + module_tiles + testimonial.\" Different from templates\n(which are whole-page starters bound by `kind`): presets are\nbuilding blocks within a single page, not entry points for new\nones.\n\nThis commit ships the foundation; the UI integrations (save-as-\npreset button in the editor + slash-menu picker) land in the next\ncommit so the diff stays bounded.\n\n**Migration** — `0213_0214_website_section_presets.sql`. New table\n`website_section_presets`:\n\n- `id`, `org_id`, `slug` (unique within org), `name`,\n  `description`, `sections jsonb`, `preview_summary`,\n  `status enum('draft', 'published', 'archived')`, soft-delete\n  `deleted_at`, audit timestamps + actors.\n- Unique index on `(org_id, slug)`.\n- Filtered index on `(org_id, status)` where `deleted_at IS NULL`\n  for the slash-menu lookup hot path.\n\n**Drizzle schema** — `websiteSectionPresets` table +\n`websiteSectionPresetStatus` enum added to\n`packages/db/src/schema/website.ts`. Follows the same shape as\n`websiteTemplates` minus the kind-binding / starter-template\nfields.\n\n**4 new actions**:\n\n- `website.preset.create(slug, name, description?, sections,\n  previewSummary?, status?)` — slug uniqueness enforced server-\n  side with a friendly `conflict` error. Gated by\n  `platform:website:page:create`.\n- `website.preset.update(id, ...optional fields)` — slug is\n  immutable (it's the stable identifier the slash-menu shows).\n  Gated by `platform:website:page:update`.\n- `website.preset.delete(id)` — soft-delete (sets `deleted_at` +\n  flips status to `archived`). Gated by\n  `platform:website:page:archive`. Tagged `dangerous: true`.\n- `website.preset.list({ status?, includeDeleted?, limit? })` —\n  sorted by `updatedAt desc`. Gated by\n  `platform:website:page:read`.\n\n**Permission story** — piggybacks on the existing\n`platform:website:page:*` namespace rather than introducing a new\none. Presets are a power-user affordance on top of the page\neditor, not a separately-governed surface; operators who can edit\npages can manage presets.\n\n8 new unit tests covering conflict / not_found / policy_denied /\nhappy paths across all four actions. 267/267 website tests still\ngreen.\n\nComing next:\n- \"Save N selected sections as preset\" affordance in the\n  page editor (multi-select sections → name + save).\n- Slash-menu integration — when the operator opens the block\n  picker, published presets show up under a \"Presets\" group; one\n  click drops the preset's sections at the cursor position.\n- Admin route `/saas/website/presets` for list / rename / delete.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-presets-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"29174234-22d0-464f-bc24-abcad302cd5e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-dispute-in-app-alert","type":"added","scope":"payments","summary":"Payment disputes now also raise an in-app bell notification for owners and admins, not just an email.","body":"A disputed payment is a time-sensitive money event with an evidence deadline,\nbut until now it only sent an email — easy to miss. Owners and admins now also\nget an in-app notification in the bell, linking straight to the payments admin\nto review and gather evidence. The flow is registered in the notification\nmatrix (`payments.dispute.opened`), so admins can tune the channels. The email\npath is unchanged; this only adds the in-app channel.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-dispute-in-app-alert.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d268d345-41a8-4f3c-9bc4-bb831f7fb573","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-empty-card-prompts","type":"changed","scope":"website","summary":"Phase 16.K.4 — collapsed section cards with no content show a primary-tinted \"Set the headline →\" / \"Add feature tiles →\" prompt instead of a generic \"(empty)\" badge.","body":"After adding a new section block (Hero / FAQ / FeatureGrid / etc.)\nthe card landed collapsed with the placeholder summary `(empty)`.\nOperators new to the editor didn't know what to do — the card\nlooked broken rather than ready-to-fill.\n\nThis commit replaces the generic `(empty)` with a per-type tap-\ntarget prompt:\n\n- `hero` → \"Set the headline →\"\n- `prose` → \"Write the body markdown →\"\n- `feature_grid` → \"Add feature tiles →\"\n- `faq` → \"Add Q&A pairs →\"\n- `trust_strip` → \"Add trust badges →\"\n- `logo_strip` → \"Add partner logos →\"\n- `comparison_table` → \"Add columns + rows →\"\n- `diagram_flow` → \"Add diagram steps →\"\n- `cta_footer` → \"Set the closing heading + CTAs →\"\n- `testimonial` → \"Add a quote + attribution →\"\n- `stat` → \"Set the value + label →\"\n- `module_tiles` → \"Pick which modules to highlight →\"\n- `global_ref` → \"Pick a global slug to reference →\"\n- `eyebrow` → \"Set the eyebrow text →\"\n- `pricing_cards` → \"(auto-renders pricing — no setup needed)\"\n\n**isSectionEmpty()** — pure predicate that returns true when the\nexisting `sectionSummary()` would have returned `(empty)`, a\nzero-count, or `(no modules)`. Drives which label renders.\n\nThe prompt sits in the same row position as the existing summary,\nprimary-tinted (`text-primary font-medium`) so it reads as a\ntap-target. Operators click anywhere on the card header to expand\ninto the form — the existing toggle behavior is unchanged.\n\nPure UI; no schema, no actions, no test changes; 280/280 module\ntests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-empty-card-prompts.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"c4f97ad3-45f5-4b3b-ba5a-c7b51bdbf15a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-image-input-unified","type":"changed","scope":"website","summary":"Phase 16.K.14 — ImageInput extracted from sections-editor; blog meta's \"Featured image\" now uses the same MediaPicker-aware widget.","body":"`ImageInput` (a text URL field paired with a \"pick from media\nlibrary\" button + a 48px thumbnail preview) was inlined inside\n`sections-editor.tsx`. Other editor surfaces — most notably the\nblog meta panel's \"Featured image URL\" — fell back to a plain\n`<Input type=\"url\">` with no library button + no preview.\nOperators dropped to the Media admin in a separate tab + copy-\npasted URLs.\n\nThis commit:\n\n- **Extracts ImageInput to its own file** —\n  `apps/web/src/components/website/image-input.tsx`. Same shape,\n  same behavior; now importable from anywhere in the website\n  editor.\n- **Removes the inline copy** from sections-editor.tsx + drops\n  the now-unused `ImageSquare` / `MediaPicker` imports there\n  (they re-import through `./image-input` instead).\n- **Wires the blog meta panel** —\n  `BlogMetaPanel`'s \"Featured image URL\" field is now an\n  ImageInput. Operators get the Library button + thumbnail\n  preview without leaving the editor.\n\nThe extracted component is unchanged behaviorally — same\nplaceholder default, same onError thumbnail hide, same\nMediaPicker wiring. Future editor surfaces (template forms,\nsection-def field types, globals data forms) can adopt the same\nimport.\n\nPure UI refactor + one new consumer; no schema, no actions, no\ntest changes; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-image-input-unified.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7729bc18-3b2f-45a4-98db-817f53133879","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-in-context-editing","type":"added","scope":"website","summary":"Phase 16.G — click a section in the preview iframe to scroll + expand the matching editor card. Real page-builder UX.","body":"The page editor + live preview pane are now a real visual page\nbuilder: clicking anywhere on a rendered section inside the\npreview iframe scrolls the editor's matching card into view and\nexpands it — no more \"which card was this again?\" round-trips.\n\nThe plumbing, in order:\n\n1. **Marketing renderer** —\n   `apps/marketing/src/components/cms/website-page-renderer.tsx`\n   wraps every rendered section in a passive\n   `<div data-section-index={idx} data-section-type={section.type}>`.\n   The attribute is harmless on real public pages; the in-context-\n   editing script (only loaded on `/preview/[id]`) reads it to\n   identify which section was clicked.\n\n2. **Preview page** — `apps/marketing/src/pages/preview/[id].astro`\n   injects an inline script + style block that:\n   - Renders a dashed primary-tinted outline on hover for every\n     element carrying `[data-section-index]`. Operators see at a\n     glance which slice of the page is clickable.\n   - Listens for clicks (delegated, capture-false) on the\n     document, walks up to the nearest `[data-section-index]`\n     ancestor, and posts\n     `{ type: 'helios:section-click', index }` to `window.parent`.\n   - Skips clicks on interactive ancestors (`a`, `button`, `input`,\n     `[role=\"button\"]`, etc.) so operators can still feel the live\n     hover/click states of CTAs, accordions, etc.\n   - Only activates when running inside an iframe (`window !==\n     window.parent`) — direct visitors to `/preview/<id>` don't get\n     click-to-edit chrome.\n\n3. **PreviewPane (admin)** —\n   `apps/web/src/components/website/preview-pane.tsx` accepts a new\n   `onSectionClick?: (index: number) => void` prop. Adds a\n   `window.message` listener that filters for the\n   `'helios:section-click'` shape and forwards the index.\n\n4. **Editor route** — `apps/web/src/routes/saas/website.$id.tsx`\n   wires `onSectionClick` to dispatch a `helios:focus-section`\n   `CustomEvent` on `document`. The DOM-event hop sidesteps a refs/\n   imperative-handle dance — the editor route doesn't need to\n   know about SectionsEditor's internal state.\n\n5. **SectionsEditor** —\n   `apps/web/src/components/website/sections-editor.tsx` listens\n   for `helios:focus-section`, validates the index against the\n   current `sections.length`, and calls the same `jumpToSection()`\n   that the outline rail uses (Phase 16.G commit). Result: scroll\n   + auto-expand land in one path.\n\nCross-origin postMessage uses `targetOrigin: '*'` because the\nadmin (apps/web on its own host) and marketing (apps/marketing on\nheliosworks.com) are guaranteed to differ in production. The\nmessage payload is a fixed shape with a static `type` discriminator\n+ a number, so there's no exfiltration surface to worry about.\n\n259/259 module tests still green. Pure UI plumbing; no schema, no\nnew actions.\n\nThe triad (preview pane + outline rail + in-context click) now\ncovers the three navigation paths a Payload-style visual editor\nprovides: scroll the editor stack, click the outline, or click\nthe rendered page itself.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-in-context-editing.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6ebf9085-063c-4d83-bb38-bd59c9fb756e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-insert-at-position","type":"changed","scope":"website","summary":"Phase 16.K — section editor gets a hover-revealed \"+ Insert\" affordance between every card. Slash menu now drops new blocks (and presets) at the cursor instead of always appending.","body":"Operators with 10+ section pages spent half the editing session\nreordering newly-added blocks because every add went to the\nbottom. This commit closes that gap with two changes:\n\n1. **Inter-card \"+\" affordance** — a faint horizontal line with a\n   `+ Insert` pill, hidden by default and revealed on hover or\n   keyboard focus, between every pair of section cards. Click\n   opens the slash menu with `insertAtIndex` pinned to that\n   position. Hidden when the editor is disabled (read-only).\n2. **Position-aware insertion** in `add()` and `insertPreset()`\n   — both now respect `insertAtIndex` when set (splices in at\n   that position), or append (existing behavior) when null. The\n   `expandedIndices` map shifts forward correctly so previously-\n   expanded cards stay expanded under their new positions.\n\n`insertAtIndex` resets after every insert + every palette close\nso the next fresh slash press defaults back to \"append\" — the\nposition-pin is a one-shot operation, not sticky.\n\nPure UI on existing state; no schema, no actions, no test\nchanges; 280/280 module tests still green.\n\nComing next (queued):\n- Per-card \"Insert below\" overflow-menu item for keyboard-only\n  operators.\n- Drag-to-insert via the slash menu's command palette (drop a\n  block by dragging instead of clicking).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-insert-at-position.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f64a23cc-056d-4ea4-8f72-39783e3f6234","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-insert-below-button","type":"changed","scope":"website","summary":"Phase 16.K.5 — per-card \"Insert below\" `+` icon in the section header opens the slash menu pinned at idx+1.","body":"The inter-card hover \"+ Insert\" affordance (Phase 16.K) lives in\nthe negative space between cards. Operators using mostly keyboard\nor who reach for the card's overflow toolbar didn't have a\nmatching path.\n\nThis commit adds a `+` icon button to every card's header, between\nthe Move-down arrow and the Duplicate icon. Click opens the slash\nmenu with `insertAtIndex` pinned to `idx + 1` — the new block\nlands directly below the card the operator clicked.\n\nSame underlying flow as the inter-card affordance (Phase 16.K) +\nthe keyboard / Cmd+/ shortcut paths — all three converge on the\nsame `openPickerAt()` + `insertAtIndex` state.\n\nDisabled when the editor is read-only; locked sections still\nallow inserting *after* themselves (the lock prevents move /\ndelete / duplicate, not insertion at a nearby position).\n\nPure UI on existing state; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-insert-below-button.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8bf7c7cf-a50b-4c02-9b84-ce7d4d71c348","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-qualify-activity-hardening","type":"fixed","scope":"crm","summary":"Lead qualification and activity logging are now atomic and tenant-safe.","body":"Qualifying a lead (company + contact + optional starter deal + the lead's\nconverted status) now commits as a single transaction, so an error part-way\nthrough no longer leaves a half-converted lead. Company matching during qualify\nis also case- and whitespace-insensitive, so \"Acme\", \"acme \" and \"ACME\" reuse\none company instead of spawning duplicates.\n\nLogging a CRM activity now verifies every linked contact / company / deal / lead\nbelongs to your organization before writing, and the \"last activity\" timestamp it\nstamps is organization-scoped — closing a cross-tenant edge case.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-qualify-activity-hardening.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f9469ca6-a4c9-4030-b892-21533dac9982","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-insert-keyboard-shortcut","type":"changed","scope":"website","summary":"Phase 16.K.18 — focused card responds to `I` (insert after) + `Shift+I` (insert before). Inter-card \"+\" mouse affordance now skips the Tab cycle.","body":"The inter-card \"+ Insert\" affordance from Phase 16.K was mouse-\nfirst — hover or focus-visible to surface, click to insert at\nthat position. For keyboard operators it was both noisy (every\ngap added a Tab stop on a 20-section page = 21 extra stops) AND\nslow (Tab to gap → Enter → picker).\n\nThis commit cleans up both ends:\n\n- **`tabIndex={-1}` on the inter-card \"+\"** — skipped from Tab\n  cycle. The button stays clickable + focus-visible-revealed\n  for mouse users; keyboard users use the new shortcuts instead.\n- **`I`** on a focused card → opens the slash menu pinned to\n  insert **after** that card (`insertAtIndex = idx + 1`).\n- **`Shift+I`** on a focused card → opens pinned to insert\n  **before** (`insertAtIndex = idx`).\n- Locked cards still permit insertion at neighboring positions\n  — the lock blocks moves/edits/deletes OF the card, not\n  insertion AROUND it.\n\nCheat-sheet (opened via `?`) gains the two new entries between\nthe existing Cmd+D / Del rows.\n\nPure UI on existing state; no schema, no actions, no test\nchanges; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-insert-keyboard-shortcut.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"73daef56-de2e-4630-b94c-1d0f9b1c7460","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-mobile-editor","type":"changed","scope":"website","summary":"Phase 17.B.3 — page editor + preview pane are responsive. Below `lg` the preview stacks under the editor instead of crushing it to a sliver.","body":"The UX audit flagged the editor as desktop-only when the\npreview pane was open. The previous layout was hard-coded\n`grid h-[calc(100vh-3.5rem)] grid-cols-[minmax(0,1fr)_minmax(\n420px,55%)]` with no media query — on a phone or 768px tablet\nthe editor column collapsed to a sliver and the action-bar\nbuttons stacked one-per-row before reaching the form.\n\nFix:\n\n- **Outer layout** is now `lg:grid` with the grid template only\n  applied at `≥lg`. Below that, the editor wrapper renders as\n  a plain stacked column with `px-4 py-6` mobile padding.\n- **PreviewPane** is `h-[60vh] border-t` on small viewports\n  (slides in below the editor); `lg:sticky lg:top-0 lg:h-screen\n  lg:border-l` at desktop sizes (the side-by-side split). The\n  PreviewPane's viewport switcher + iframe + footer all\n  work unchanged.\n- Operators on phone / tablet now scroll: editor first, preview\n  next. Useful for quick on-the-go content checks even though\n  the editor isn't fully optimized for touch yet (drag-drop is\n  HTML5-native, which is finicky on touch).\n\nPure UI / Tailwind responsive classes; no schema, no actions,\nno test changes; 280 / 46 tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-mobile-editor.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d90dcfc3-094d-46ab-b226-42ba70a29764","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-page-duplicate","type":"added","scope":"website","summary":"Phase 16.J — one-click page duplication. New action `website.page.duplicate` + per-row Duplicate button on /saas/website list creates a draft copy and jumps you into the editor.","body":"Operators copy-paste a working page when they want to A/B-test\nvariations, scratch out a 2026 refresh, or fork a comparison page\ninto a sibling. The old path was three clicks across two routes\n(open source → copy sections JSON → New page → paste). This commit\nmakes it one click from the page list.\n\n**New action** — `website.page.duplicate(sourceId, { targetSlug?,\ntargetLanguage?, titleOverride? })`:\n\n- Defaults: `targetSlug = ${source.slug}-copy`,\n  `targetLanguage = source.language`,\n  `title = ${source.title} (copy)`.\n- Carries sections + meta + tags + `ogImage` + `noindex` verbatim\n  from the source so the new draft is structurally identical;\n  `canonical` resets to null so the operator picks a fresh\n  canonical URL when ready.\n- Always lands as `status: 'draft'` with `publishedAt: null` and\n  `publishAt: null` — the source's lifecycle doesn't propagate.\n- Returns `conflict` on the standard `(org, kind, target_slug,\n  target_language)` uniqueness collision with a friendly error\n  message naming the conflicting slug.\n- Refuses archived rows with `conflict` (\"Cannot duplicate an\n  archived/deleted page.\").\n- Runs the same Phase 9 slug-collision detector so cross-kind\n  audits stay accurate.\n- Emits `website.page.created` so existing subscribers\n  (search-index refresh, sitemap rebuild) fire normally.\n\nUse `translate` for cross-language clones (so the audit-log\nintent is explicit); `duplicate` is the within-language path.\n\n**Per-row Duplicate button** on `/saas/website` — ghost-styled,\nright-aligned in a new column visible only when the actor has\n`platform:website:page:create`. On click, fires the mutation;\ntoast on success; the inventory + list queries invalidate; the\noperator navigates straight to the new draft's editor.\n\n5 new tests covering not_found / archived-conflict / default-slug\n+ title / explicit overrides / policy-denial. 280/280 module tests\ngreen.\n\nComing next (queued, not in this commit):\n- Bulk duplicate (matching the inventory bulk-adopt pattern).\n- \"Duplicate to language…\" dropdown that combines duplicate +\n  translate intent in one menu.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-page-duplicate.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"0d80797e-dbca-4d63-b4a7-3375484f6467","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-page-inventory","type":"added","scope":"website","summary":"Phase 16.F — /saas/website/inventory surfaces every operator-editable marketing route with CMS status. Operators see \"what's hardcoded vs in the CMS\" at a glance.","body":"The CMS admin only showed pages that had been seeded as CMS rows.\nOperators wanting to edit /about, /pricing, /careers etc. had no\nway to know whether the route was hardcoded (Astro file) or\nCMS-managed without poking around. This commit closes that gap.\n\n**New catalog** — `modules/website/src/lib/page-inventory.ts`. Hand-\nmaintained list of 33 marketing routes with `{ path, kind, slug,\ntitle, description, category, highPriority }`. Excludes dynamic\nroutes (`blog/[...slug]`, `integrations/[slug]`, `product/[slug]`)\n— those already render CMS rows directly — plus infrastructure\npages (login / signup / preview).\n\n**New action** — `website.page.list_inventory`. Joins the catalog\nwith the org's CMS rows (kind + slug + language='en') and returns\neach entry's `cmsStatus` (`absent` / `draft` / `pending_review` /\n`published` / `archived`) + `cmsPageId` (or null). Plus a `counts`\nstrip (`total / inCms / hardcoded / published`) for the header\ngauge. Read-only; gated by `platform:website:page:read`.\n\n**New admin route** — `/saas/website/inventory.tsx`. Renders:\n\n- **Coverage strip** — total / in-CMS / hardcoded / published\n  counts + a progress bar showing CMS coverage %.\n- **Grouped tables** — entries grouped by category (Home / Product\n  / Pricing / Solutions / Compare / Company / Blog / Changelog /\n  Trust & support / Errors).\n- **Per-row affordances** — \"Edit in CMS →\" when the page has a\n  row, \"Adopt (coming)\" placeholder otherwise. The Adopt action\n  ships in the next commit.\n- **High-priority pill** — pages flagged as `highPriority: true`\n  in the catalog (home, pricing, careers, about, contact, trust,\n  product, AI) get a primary-tinted Pill so operators tackle them\n  first.\n\n**Sidebar entry** — \"Inventory\" link added to the Marketing-site\ngroup between \"Pages\" and \"Pending review\" so operators discover\nit without knowing the URL.\n\n253/253 module tests + 262/262 email tests still green.\n\nComing next:\n- `website.page.adopt_from_hardcoded` action — creates a starter\n  CMS row prefilled with title + description from the inventory\n  catalog (a sensible default sections array per kind), then\n  redirects the operator to the editor.\n- \"What's missing\" badge on /saas/website — when more than half\n  the inventory is still hardcoded, surface a banner pointing at\n  /saas/website/inventory.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-page-inventory.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fbac2bd2-361d-4059-802e-ee7ea6442d7f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-pending-inline-actions","type":"changed","scope":"website","summary":"Phase 16.E.2 polish — /saas/website/pending exposes one-click Approve + a Reject-with-reason dialog inline. Approvers can burn down 10 pages without leaving the queue.","body":"The approval queue at /saas/website/pending used to be link-only:\nevery row pointed at /saas/website/$id where the operator had to\nclick Approve & publish or Reject (with the reason dialog) per\npage. For 10+ items in the queue, that's 10 round-trips.\n\nThis commit puts both actions inline:\n\n- **Approve** — primary-tinted button, one click, no confirm\n  dialog. Calls `website.page.publish` directly. Toast on success,\n  refetch invalidates the queue + the main list.\n- **Reject** — danger-tinted button that opens a small\n  `ConfirmDialog` asking for the required reason (4-row Textarea,\n  2000-char cap, autofocused). On confirm calls\n  `website.page.reject_review` + same invalidation.\n- **↗** ghost icon-link — preserved as a one-click \"open the\n  editor\" affordance for cases where the approver needs to read\n  the full page before deciding.\n\nNon-approvers still see only the \"Open →\" link (no change for\nauthors browsing the queue).\n\nThe reject dialog mirrors the one on the page editor route — same\ncopy, same Textarea sizing, same destructive variant. Operators\nwho already know the editor flow recognize it.\n\nBoth inline actions feed into the editorial-loop emails:\n- Approve → `website.page.review_approved` event → author gets\n  the \"your page is live\" email.\n- Reject → `website.page.review_rejected` event → author gets\n  the rejection email with the reason quoted.\n\nPure UI; no schema, no actions, no test changes; 253/253 website\n+ 262/262 email tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-pending-inline-actions.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"343a3114-fa81-4c39-804a-d553a7d3f8e2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-presets-admin-route","type":"added","scope":"website","summary":"Phase 16.I admin surface — /saas/website/presets lists every saved preset with edit / archive / expand-to-see-sections.","body":"The presets CRUD actions + editor integration shipped in the\nprevious commits had no curation surface. Operators saving 10+\npresets needed somewhere to rename, archive stale ones, or audit\nwhat each preset contained. This commit closes that loop.\n\nNew route `/saas/website/presets`:\n\n- **List view** — paginated (200/page is plenty for the lifetime\n  of an org's preset library) with name + slug + section count +\n  status + last-updated. Sorted by `updatedAt desc`.\n- **Show archived toggle** — checkbox in the page header. Off by\n  default so the table reads as \"what's live\"; on to recover or\n  audit historical presets.\n- **Expandable rows** — click the chevron to see the preset's\n  section composition (ordered list of section types as outline\n  badges). No round-trip — the section types come back in the\n  same `website.preset.list` payload via `sections`.\n- **Edit dialog** — rename, update description, flip status\n  (published / draft / archived). Slug stays immutable (it's the\n  stable identifier scripts / docs reference). Section composition\n  is read-only here; operators edit the underlying page that\n  saved the preset and re-save with the same name to overwrite.\n- **Archive flow** — primary-tinted ConfirmDialog with copy that\n  clarifies the no-side-effect-on-existing-pages contract.\n\nSidebar entry \"Presets\" added under the Marketing site group\nbetween \"Section types\" and \"Blog\" so operators discover it\nwithout knowing the URL.\n\nPermission gating mirrors the actions: `:read` for the list,\n`:update` for edit, `:archive` for archive. Operators who can\nread but can't update don't see the Edit button.\n\nPure UI; no schema or action changes. 267/267 module tests still\ngreen.\n\nThe presets loop is now end-to-end:\n1. **Save** — from any page editor (Save as preset… in the\n   toolbar).\n2. **Insert** — from any page editor's slash menu (Presets group).\n3. **Curate** — from /saas/website/presets (rename, archive,\n   audit section composition).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-presets-admin-route.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ff88a0de-d181-4ae6-874e-ae599499b917","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-preview-cross-org-fix","type":"security","scope":"website","summary":"Phase 17.A.2 — preview tokens now encode orgId in the HMAC payload + read handler scopes the page lookup by orgId. Closes cross-org draft enumeration via a leaked secret.","body":"The data-layer audit flagged `getPreviewPage` as Tier 1 security:\nthe handler read `where(eq(websitePages.id, input.id))` with no\n`orgId` filter, relying solely on the HMAC for access. If the\npreview secret ever leaked, an attacker could enumerate every\ntenant's drafts by page id.\n\nThis commit closes that gap by encoding the org boundary into\nthe token itself:\n\n**Token format change** —\n- Old: `<expiresAtMillis>.<sig>` with HMAC over `<pageId>.<expiresAtMillis>`.\n- New: `<orgId>.<expiresAtMillis>.<sig>` with HMAC over `<pageId>.<orgId>.<expiresAtMillis>`.\n\n**Handlers**:\n- `mintPreviewToken` passes `ctx.orgId` into `signToken` —\n  every minted token now carries the minter's org.\n- `getPreviewPage` calls `verifyTokenAndExtractOrg(pageId, token)`\n  which returns the orgId on valid signature, null otherwise.\n  The page lookup is then scoped to that orgId. Even with a\n  leaked secret, an attacker would also need a valid org-scoped\n  signature for the target tenant.\n\n**Back-compat note**: old tokens (pre-Phase 17) no longer\nverify. The 15-minute TTL means at most one preview window's\nworth of breakage during rollout; operators just re-click\n\"Preview draft →\" to mint a fresh token.\n\n280/280 module tests still green; typecheck clean.\n\nComing next in Phase 17.A:\n- `restoreRevision` whitelist enforcement.\n- Scheduled-publish + pending-review orphan resolution.\n- `pricing_cards` renderer fix.\n- `/product/[slug].astro` accent ternary bug fix.\n- `/preview/[id].astro` pre-fetches globals + section-defs + plans.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-preview-cross-org-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"dba5e29a-bbac-44ca-8180-4ded46688c04","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-pricing-cards-renderer-fix","type":"fixed","scope":"website","summary":"Phase 17.A.4 — the `pricing_cards` CMS section now actually renders PricingCard tiles from plans instead of falling through to a \"see /pricing\" pointer.","body":"The renderer audit flagged this as Tier 1: the\n`pricing_cards` case in `website-page-renderer.tsx` did `if\n(plans.length === 0) ... else still just render a \"see /pricing\"\npointer`. The `<PricingCard>` component existed in\n`blocks/pricing-card.tsx` but was never invoked. Operators\ndragging the block onto a page saw nothing render.\n\nThis commit wires the real component:\n\n- Iterates `external.plans` (passed by the Astro frontmatter\n  from `saas.plan.list_public`).\n- Per plan, builds the `features` list from\n  `plan.features.{ai_actions_per_day, sso, custom_domains,\n  modules, …}` with sensible per-tier strings (e.g., \"Unlimited\n  AI actions\" for `null`, \"Up to 25 seats\" for capped, \"Every\n  module\" for `'*'`).\n- Maps billing interval to the label suffix (\"/year\", \"/month\",\n  or blank for `'none'`).\n- Default highlight: 'business' tier (the typical \"most\n  popular\" pick); operator-overridable via\n  `section.highlightSlug`. The plan's own `highlight: true`\n  flag also wins.\n- CTA per tier: $0 → \"Start free → /signup\", null → \"Talk to\n  sales → /contact\", otherwise \"Start trial → /signup\".\n- Trust-note per tier: free shows \"No credit card · 3 free\n  seats\", paid plans with a positive trial show \"N-day free\n  trial\".\n- Responsive grid: `sm:grid-cols-2` always; `lg:` matches the\n  tier count (2 / 3 / 4) so a 3-plan page lays out as 3 columns\n  and a 4-plan page as 4.\n\nMarketing 46/46 tests still green; no schema or action changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-pricing-cards-renderer-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"574adb38-27b1-4579-be93-195475db01af","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-repeater-drag-reorder","type":"changed","scope":"website","summary":"Phase 16.K.2 — repeater rows inside section forms (feature_grid tiles, faq Q&As, logo strip, trust badges, diagram steps, comparison table rows) are now drag-reorderable.","body":"The Phase 16.A.2 native HTML5 drag-drop only reordered top-level\nsection cards. The inner items inside complex sections — the\n3-12 tiles of a feature_grid, the Q&A pairs of an FAQ, the\ncolumns of a comparison table, the steps of a diagram flow, the\nlogos of a logo strip, the badges of a trust strip — were\nadd-bottom-only with tiny Up/Down arrows. For longer lists\noperators clicked the arrow 7 times to move an item to position\n3.\n\nThis commit upgrades the shared `ListEditor` (used by every\nrepeater form in the section editor) with the same native drag-\ndrop pattern:\n\n- Each item is `draggable` whenever the editor isn't disabled\n  AND there's more than one row (single-item lists have nothing\n  to reorder).\n- Source row fades to `opacity-50` while held.\n- Hover target lights up with a `ring-2 ring-primary` outline +\n  a thin top edge marker when dragging upward from a higher\n  source.\n- The row header picks up `cursor-grab` / `active:cursor-grabbing`\n  to telegraph the affordance.\n- `moveTo(from, to)` splice logic adjusts the target index when\n  the source comes from a lower position (same as the section-\n  level reorder).\n- Up/Down arrow buttons stay as the keyboard-accessible path —\n  drag-drop is mouse-first enhancement.\n\nInherits-by-default: every section form that uses ListEditor\ngets drag-reorder for free. That's:\n- `feature_grid.tiles`\n- `faq.items`\n- `logo_strip.logos`\n- `trust_strip.badges`\n- `diagram_flow.steps`\n- `comparison_table.rows`\n\nPure UI on shared component; no schema, no actions, no test\nchanges; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-repeater-drag-reorder.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4df04827-324e-47b4-bc30-a98e0f4f617b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-restore-revision-whitelist","type":"fixed","scope":"website","summary":"Phase 17.A.3 — restore_revision now validates the restored sections against the effective allowedBlockTypes whitelist. Plus transactional snapshot + restore.","body":"The data-layer audit flagged Tier 1: `restoreRevision` wrote the\nrevision's section array directly without re-checking the\nper-page `meta.allowedBlockTypes` whitelist. If the whitelist\ntightened after a revision was snapshotted, restoring it silently\nre-introduced forbidden block types — the row would then fail the\nNEXT save's validation, leaving the operator in a dead-end\n\"saved-but-now-invalid\" state.\n\nThis commit closes that:\n\n- **Whitelist check** uses the most-restrictive of `currentRow.\n  meta.allowedBlockTypes` and `revisionRow.meta.allowedBlockTypes`\n  — so pages stay in bounds whether the whitelist tightened in\n  the revision or since.\n- Returns `validation_failed` with the specific disallowed type\n  names + actionable copy: \"Update the allowedBlockTypes whitelist\n  first, or pick a different revision.\"\n- Same `findDisallowedSectionTypes` helper the create/update path\n  uses — single source of truth.\n- Also wraps the snapshot + restore in `runInTransaction()` per\n  Phase 17.A.1 so partial failures roll back atomically.\n\n280/280 module tests still green; typecheck clean.\n\nComing next in Phase 17.A:\n- `pricing_cards` renderer fix (Tier 1 from renderer audit).\n- `/product/[slug].astro` accent ternary bug fix.\n- `/preview/[id].astro` pre-fetches globals + section-defs + plans.\n- Scheduled-publish + pending-review orphan resolution.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-restore-revision-whitelist.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ec4d8aae-7f2c-4c75-ab55-eba68d7749ae","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-review-approved-email","type":"added","scope":"website","summary":"Phase 16.E.2 polish — author gets a \"your page is live\" email when an approver publishes their submission. Closes the editorial loop.","body":"Third side of the editorial workflow: when an approver publishes a\npending_review CMS page, the author who submitted it now gets a\nconfirmation email with a link to the live URL on the marketing\nsite.\n\n**Event** — new `website.page.review_approved`. Emitted by\n`website.page.publish` ONLY when the prior status was\n`pending_review` — direct publish from draft doesn't fire this\nevent because there's no review loop to close. Payload carries\n`{ id, orgId, slug, kind, approvedBy, authorUserId, publishedAt }`.\n\n**Shared helper** — extracted the author-resolution pattern (walk\nrecent revisions, find the latest \"Submitted for review\", fall\nback to `createdBy`) to `modules/website/src/lib/review-author.ts`\nso the reject + approve paths share it. The reject_review handler\nnow uses the helper instead of its inlined query — pure refactor,\nno behaviour change.\n\n**System template** — `website.page.review_approved` with a green\nleft-edge accent (vs amber for request, red for reject), the\noperator's live URL resolved from `platform_settings.marketingUrl`,\nand a secondary \"Open in editor\" link for follow-up edits.\n\n**Flow entry** — `website.page.review_approved` registered in\n`EMAIL_FLOWS` as `important: true`. Owner module: `website`.\n\n**Subscriber** —\n`modules/website/src/jobs/email-on-review-approved.ts`. Same shape\nas the request + reject subscribers; resolves the live URL via\n`platform_settings.marketingUrl` so operator deployments link to\ntheir own domain instead of the codename's defaults.\n\n**Worker wiring** — `registerWebsiteJobs({ db })` now wires all\nthree subscribers in one call. apps/worker boot unchanged since\nthe prior commit.\n\n262 / 262 email tests + 253 / 253 website tests still green.\n\nEditorial loop closed:\n\n| Event | Recipient | Color accent |\n|---|---|---|\n| `request_review` | Assigned editor | amber (#FBA82C) |\n| `review_approved` | Author | green (#10B981) |\n| `review_rejected` | Author | red (#DC2626) |\n\nQueued (not in this commit):\n- Multi-recipient fan-out when an approver wants to notify the\n  whole approver pool (today: just the author / assignee).\n- In-app notification mirror via `dispatchNotification` (the email\n  path is the high-leverage one; the bell-icon channel can wait).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-review-approved-email.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"eb0451f9-a423-438b-80b1-251fd2fc9c77","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-notes","type":"added","scope":"website","summary":"Phase 16.K.7 — per-card operator-private notes (\"Awaiting legal sign-off\", \"Don't change until Q3\"). Round-trips through meta.sectionNotes; doesn't render publicly.","body":"Marketing teams need a place to leave context per section for\ncoworkers — pending sign-offs, blocked-on-X notes, \"don't touch\nthis until product calls it\" reminders. Until now there was no\nin-editor surface for that; operators left external Slack threads\nor stale doc comments.\n\nThis commit adds **internal notes per section card**:\n\n- **Per-card Note icon** in the section header. Filled + primary-\n  tinted when a note exists; muted outline otherwise. Hover\n  tooltip shows the note inline so operators don't have to open\n  the dialog for a quick read.\n- **Inline note editor dialog** — 5-row Textarea, 2000-char cap.\n  Saving empty text removes the note.\n- **Storage** — `meta.sectionNotes` is a `Record<string, string>`\n  keyed by section index (string-cast for stable JSON shape).\n  Read on the editor route via `extractSectionNotes(metaText)`;\n  written back via a spread-and-omit pattern that preserves every\n  other meta field.\n- **Public render never sees notes** — the field lives only in\n  the admin meta blob; the marketing renderer doesn't read it.\n\n**Caveat documented in the dialog copy** — notes attach to the\nsection's **position** (slot N), not the section itself. When\noperators move a card via drag-drop or Up/Down, the note stays at\nthe slot rather than following the section. The MVP accepts this\ntrade-off; future work could swap to id-keyed notes once sections\ncarry stable ids.\n\nThe notes feature only renders when the editor route passes\n`onSectionNotesChange` — globals / templates / preset editors\nomit it (no relevant meta blob to round-trip through).\n\nPure UI + a thin meta round-trip helper; no schema changes, no\nnew actions, no migration; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-notes.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"417a28e1-b3b2-4497-8f6c-728baca07a58","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-outline","type":"added","scope":"website","summary":"Phase 16.G — togglable section outline rail in the page editor; click any entry to jump + auto-expand the matching card.","body":"Page editors with 15+ sections used to be a wall of cards. Operators\nspent half the editing session scrolling. This commit adds a\ntogglable outline rail that lets you scan + navigate the whole page\nstructure at once.\n\nWhat lands in `apps/web/src/components/website/sections-editor.tsx`:\n\n- **Outline toggle** — new `Outline (N)` button on the toolbar (shown\n  alongside the existing Expand all / Collapse all controls when 2+\n  sections exist). Toggles the rail open/closed; choice persists\n  per-session via localStorage so the operator's preference survives\n  navigation.\n- **Outline rail** — when open, renders above the section cards as a\n  compact list:\n  - Numbered index (`1`, `2`, …) right-aligned in a 4-char monospace\n    column so multi-digit pages line up.\n  - Block-type label (`Hero`, `Feature grid`, `CTA footer`, …).\n  - Lock icon for sections pinned by a template.\n  - One-line summary derived from the section's primary field (same\n    derivation as the card header summary, so the outline reads as\n    a true table of contents).\n  - Active-state highlight: any currently-expanded section gets a\n    primary-tinted background in the rail.\n- **Jump-to-section** — clicking an entry expands the corresponding\n  card AND smoothly scrolls it into view. The expand fires first;\n  the scroll defers one frame so the card's height change settles\n  before we measure the landing position (otherwise smooth-scroll\n  lands above the target).\n\nPer-card data attribute — every section card now carries\n`data-section-card-idx={idx}` so the outline's\n`document.querySelector('[data-section-card-idx=\"N\"]')` resolves\nreliably regardless of how the card's outer styling changes in the\nfuture. Also unlocks the upcoming in-context-editing patch (click\ninside the preview iframe → editor scrolls + opens the matching\ncard via postMessage) — that path will use the same data-attribute\nselector.\n\nPure UI; no schema, no actions; 259/259 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-outline.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8087a9cf-e99c-4158-9d29-e53160a8bd6c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-search","type":"changed","scope":"website","summary":"Phase 16.K.13 — section editor gets a free-text filter above the cards on 5+ section pages. Non-matching cards hide; counter shows N visible / total.","body":"Pages with 15+ sections were a wall of cards. Operators looking\nfor \"the section that mentions Stripe\" had to scroll + scan\nmanually. This commit adds inline filter:\n\n- **Search input** at the top of the section editor, shown when\n  `sections.length >= 5`. Below that threshold scrolling fits;\n  search is overhead.\n- **Match logic** — case-insensitive search against the block\n  label (`Hero` / `Feature grid` / etc.) + the serialized JSON\n  of the section data. Covers every block type without per-type\n  field maps.\n- **Hidden cards collapse fully** — they render as an empty\n  Fragment so they don't take vertical space. Indices stay\n  correct (the filter is presentation-only; the underlying\n  `sections` array is untouched).\n- **Counter** in the input's right rail: `5 / 23` (visible /\n  total).\n- **Esc** clears the filter from the input.\n- **✕ clear button** in the right rail for mouse users.\n\nThe filter doesn't touch state — when cleared, every card\nreappears in its previous expand/collapse state. Drag-drop,\nkeyboard nav, bulk ops all see the full array; filtering is a\nview layer only.\n\nPure UI on existing state; no schema, no actions, no test\nchanges; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-search.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b4a7b89f-6c1c-46d7-ac35-86940a7c6439","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-subset-preset","type":"changed","scope":"website","summary":"Phase 16.I.2 — per-card checkboxes in the section editor let operators save only a subset (e.g., just the hero + cta_footer) as a preset.","body":"The Phase 16.I save-as-preset flow always saved the whole page's\nsections array. When operators only wanted to bundle the hero +\ncta_footer (or any sub-group), they had to clone the page first,\ndelete the unwanted sections, save the preset, then discard the\nclone. Three steps too many.\n\nThis commit adds per-card multi-select:\n\n- **Per-card checkbox** at the leading edge of every section card's\n  header. Hidden when the editor is disabled (read-only).\n  Click/mousedown propagation is stopped so a tick doesn't fire the\n  card's drag-start or expand handlers.\n- **Toolbar label flips** based on selection. With no boxes ticked:\n  \"Save as preset…\" (primary-neutral). With N ticked:\n  \"Save N selected as preset…\" (primary-tinted) + a \"Clear (N)\"\n  text button next to it.\n- **`sectionsToSave()` helper** — returns the subset by index when\n  any boxes are ticked, otherwise the full sections array. Selection\n  order tracks the natural array order so the saved preset matches\n  the visual order in the editor.\n- **Dialog copy updates** to reflect the count + intent:\n  \"5 selected sections will be stored (positions preserved)\" vs the\n  default all-sections message with a hint about ticking checkboxes.\n- Selection clears on successful save so the operator returns to a\n  clean state.\n\nBack-compat: operators who never tick a checkbox see the same\nbehavior as before (save everything).\n\nPure UI on the existing `website.preset.create` action — no schema\nor test changes; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-subset-preset.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d590ec6b-1b46-4030-98af-ad517ff4922c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-section-type-transform","type":"added","scope":"website","summary":"Phase 16.K.6 — change a section's block type in place while keeping compatible fields. Hero ↔ CtaFooter, FeatureGrid ↔ ComparisonTable, TrustStrip ↔ LogoStrip, etc.","body":"Operators change their mind mid-edit — a hero turns out to belong\nat the bottom as a CTA footer, a comparison_table reads better as\na feature_grid. The old path was delete + re-add + retype every\nfield. This commit ships in-place type transformation.\n\n**New helper** —\n`transformSection(currentSection, toType): Section`. Curated\nfield-carry-over rules:\n\n- `hero` ↔ `cta_footer` — heading, subheading, primaryCta,\n  secondaryCta map 1:1.\n- `hero` / `cta_footer` → `eyebrow` — heading-like text becomes\n  the eyebrow text.\n- `hero` / `cta_footer` → `prose` — synthesizes a `## heading\\n\\n\n  subheading` markdown.\n- `eyebrow` → `hero` / `cta_footer` / `prose` — eyebrow text\n  becomes heading or first markdown line.\n- `feature_grid` ↔ `comparison_table` — tiles ↔ rows by\n  title ↔ feature.\n- `feature_grid` ← `faq` — Q&A becomes title/description pairs.\n- `trust_strip` ↔ `logo_strip` — badge labels become logo alt\n  text (text-only mode) + vice versa. Preserves the caption.\n- `testimonial` / `stat` → `prose` — best-effort lossy.\n\nThe map is intentionally CURATED. Random pairs (hero →\npricing_cards) aren't offered. The result: the change-type menu\non a card only shows targets where the carry-over is meaningful;\noperators rarely see \"transform discarded my data\" surprises.\n\n**New `compatibleTargets(type): BlockType[]`** — returns the\ncurated list per source type. The card header's transform icon\nbutton hides itself when the list is empty (e.g., `pricing_cards`\nhas no compatible targets — pricing carries no portable fields).\n\n**UI** — new `ArrowsClockwise` icon button in the card header\nbetween Insert-below and Duplicate. Click opens the existing\nCommandPalette in **transform mode**: items are \"Change to <X>\"\noptions scoped to the source's compatible targets + filtered to\nthe page's `allowedBlockTypes` whitelist (Phase 11). Esc /\ndismiss without selecting clears the transform intent + resets\nthe picker so a subsequent fresh slash press defaults back to\n\"add.\"\n\nLocked sections (template-pinned) refuse the transform — same\ngate as delete / duplicate.\n\nPure UI + a new pure helper; no schema, no actions, no test\nchanges; 280/280 module tests still green.\n\nComing next (queued):\n- Confirm dialog when transform would discard >2 fields\n  (currently silent — operator can transform back to recover, but\n  a warning would help on irreversible loss).\n- Per-card \"preview the transform\" before committing.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-section-type-transform.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"de07cb68-50a4-40db-b632-58e470743c3b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-shift-click-range-select","type":"changed","scope":"website","summary":"Phase 16.K.10 — Shift+Click on section card checkboxes selects every card between the anchor and the clicked card. Speeds bulk-actions on long pages.","body":"The Phase 16.K.9 bulk actions (Move ↑↓ / Delete N / Save as\npreset) operate on whatever the operator ticked. On long pages\nticking 8 contiguous cards was 8 individual clicks. This commit\nadds the standard Shift+Click range-select pattern from\nNotion / Slack / file-managers.\n\nBehavior:\n\n- **Plain click** — toggle just this card. Resets the anchor to\n  this idx (the next Shift+Click ranges from here).\n- **Shift+Click** — additive range: every non-locked card between\n  the anchor (last-clicked) and this card gets added to the\n  selection. Never deselects — operators clear via the Clear\n  button if they need to.\n- **First click after Clear** — sets the anchor; can't\n  range-select without an anchor.\n- **Locked cards in the range** — silently skipped. Operators see\n  the disabled checkbox + the bulk count reflects only movable\n  items.\n\nImplementation:\n\n- New `lastClickedSelectIdx` state tracks the anchor.\n- New `handleSelectCheckboxClick(idx, shiftKey)` decides between\n  range-add and plain toggle, then updates the anchor.\n- Checkbox `onChange` is now a no-op; `onClick` calls\n  `e.preventDefault()` so the native toggle never races with the\n  controlled state.\n- `clearPresetSelection()` resets the anchor too — operators who\n  clear get a clean slate.\n\nCheat-sheet entry added (Shift+Click → Range-select section cards).\n\nPure UI on existing state; no schema, no actions, no test\nchanges; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-shift-click-range-select.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a43a51b5-5a6a-4719-99f3-e0fe5a828356","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-transactional-snapshots","type":"fixed","scope":"website","summary":"Phase 17.A.1 — page update / request_review / reject_review / restore wrap snapshot + row update + tag-delta in one transaction. Crash mid-write no longer leaves revision-vs-row drift.","body":"The page lifecycle handlers (`updatePage`, `requestReviewPage`,\n`rejectReviewPage`, `restorePage`) each ran their\n`website_page_revisions` snapshot insert + the `website_pages`\nrow update as two independent statements. The data-layer audit\nflagged this as Tier 1: a crash between the snapshot insert and\nthe row update leaves the history showing a revision the live\nrow never reflected; for `updatePage` a crash between the row\nupdate and the blog tag-usage delta leaves counts permanently\ndrifted.\n\nThis commit wraps all four handlers in `db.transaction()`:\n\n- **`updatePage`** — snapshot + row update + (blog) tag-usage\n  delta all commit/abort together.\n- **`requestReviewPage`** — snapshot + status flip to\n  `pending_review` together.\n- **`rejectReviewPage`** — snapshot + status flip back to\n  `draft` together.\n- **`restorePage`** — snapshot + `deleted_at` clear + status flip\n  to `draft` together.\n\nEvent emission stays OUTSIDE the transaction (events are\nbest-effort + idempotent on the consumer side; rolling back the\nevent would require an outbox pattern we don't have yet).\n\n**Test-friendly helper** —\n`runInTransaction(db, fn)` detects at runtime whether the\nunderlying db has a `.transaction` method. Production Postgres\nDrizzle always carries it; the chainable test stubs don't, and\nthe helper falls back to a sequential pass for unit tests (the\nstubs don't care about atomicity). This avoids a 60-file fixture\nupgrade across the test suite.\n\nA new `withTx()` decorator helper is also added to\n`page.test.ts` for future tests that want an explicit transactional\nwrapper around their stub.\n\n280/280 module tests still green; typecheck clean.\n\nComing next in Phase 17.A:\n- Same transactional fix for `publishPage` / `archivePage` /\n  `batch_*` (they don't currently snapshot but the publish-vs-\n  tag-delta race exists there too).\n- `getPreviewPage` cross-org leak fix (encode orgId in HMAC\n  payload).\n- `restoreRevision` whitelist enforcement.\n- Scheduled-publish vs pending-review orphan resolution.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-transactional-snapshots.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2d15a9a9-27dd-44be-aea0-fe4c9904f66f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-transform-loss-confirm","type":"changed","scope":"website","summary":"Phase 16.K.11 — section type transform now prompts before discarding fields. Lists the exact field names that won't survive; lossless transforms still apply silently.","body":"Phase 16.K.6 shipped silent type transforms — operators changed a\nhero to a cta_footer in one click. When the transform dropped\nfields (hero had a `trustNote`, cta_footer doesn't), the loss\nhappened without warning. Operators learned about it only when\nthey noticed the missing content later.\n\nThis commit adds explicit data-loss confirmation:\n\n**New helpers**:\n\n- `isMeaningful(value)` — predicate for \"does this value carry\n  user content?\" Empty strings, undefined/null, empty arrays, and\n  empty objects don't count.\n- `getLossyFields(source, transformed)` — returns the source\n  field names that are meaningful in the source but missing/empty\n  in the transformed result. Only the field NAMES (not values) are\n  returned because operators recognize them from the card form.\n\n**Updated transform flow**:\n\n- `transformAt(toType)` computes the transformed payload, then\n  calls `getLossyFields()`.\n- If 0 fields would be lost → apply silently (existing behavior).\n- If ≥1 → stash the pending transform in state + open the confirm\n  dialog. The picker closes immediately; transformation is held\n  until the operator confirms or cancels.\n\n**Confirm dialog**:\n\n- Lists the dropped field names as warning-tinted pills.\n- Reminder copy: \"You can Cmd/Ctrl+Z to undo the transform if you\n  change your mind.\" — the undo/redo from Phase 16.K.8 is the\n  recovery path, so the dialog doesn't need to be aggressive.\n- Confirm button is `danger`-styled with the count baked in:\n  \"Transform + drop 3 fields\" so operators see the consequence\n  before they click.\n\nLossless transforms (hero → cta_footer when ONLY heading +\nprimaryCta were filled in) still apply silently — most operators\nnever see the dialog.\n\nPure UI on existing helpers + transform path; no schema, no\nactions, no test changes; 280/280 module tests still green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-transform-loss-confirm.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"cd5728cd-61e9-44fe-b49e-27eb3e7969f8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-undo-redo","type":"added","scope":"website","summary":"Phase 16.K.8 — page editor gets Cmd/Ctrl+Z undo + Cmd/Ctrl+Shift+Z redo across every editable field (title, sections, meta, ...). Toolbar buttons + keyboard, 50-entry history.","body":"Operators editing aggressively (drag-reorder, type transform,\ndelete by Backspace, bulk add via presets) had no path to recover\nfrom a mis-click. The only undo was Ctrl+Z inside an individual\ntext input, which only walked the input's local history — section\nmoves, drag reorders, deletes, transforms were all \"no undo for\nyou.\"\n\nThis commit ships full editor undo/redo:\n\n**New generic hook** — `apps/web/src/lib/use-edit-history.ts`:\n\n- `useEditHistory<T>(current, restore)` — debounces snapshots into\n  an undo stack on every `current` change; calls `restore(prev)`\n  on undo / `restore(next)` on redo.\n- 50-entry cap on each stack (bounded memory across long sessions).\n- 500ms debounce — burst typing collapses to one snapshot.\n- Restore-vs-snapshot guard via ref flag — restoring doesn't\n  itself create a new history entry.\n- Skip-duplicate guard — JSON-equal snapshots don't stack.\n- `redoStack` clears on any new edit — operator's new branch\n  supersedes ahead history (matches Word / Notion / VS Code\n  behavior).\n- Exposes `{ undo, redo, canUndo, canRedo, snapshot }`. The\n  manual `snapshot()` is for callers that want to mark a step\n  boundary explicitly.\n\n**Wired into the page editor** —\n`apps/web/src/routes/saas/website.$id.tsx`:\n\n- Bundles title / description / canonical / ogImage / tagsText /\n  sections / sectionsText / metaText / noindex into one snapshot\n  shape.\n- `Cmd/Ctrl+Z` undo, `Cmd/Ctrl+Shift+Z` (or `Cmd/Ctrl+Y` for\n  Windows folks) redo. Only fires when no text input has focus —\n  inside an input the browser's native per-input undo still works\n  for character-level changes.\n- Two new toolbar icon buttons (`↶` / `↷`) in the editor header\n  for operators who prefer mouse / can't reach Cmd+Z. Disabled\n  when there's nothing to undo / redo so the buttons don't lie.\n- Cheat-sheet entry added under the existing shortcut help dialog\n  (opened via `?`).\n\nPure UI hook + a thin shortcut listener; no schema, no actions,\nno test changes; 280/280 module tests still green.\n\nThe hook is generic — future editors (globals admin, template\neditor, section-def editor) can adopt it with the same\n`useEditHistory` + restore-function pattern.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-undo-redo.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4938fd1b-c3ec-40c9-8ec8-f79fccdaf5f6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-unified-admin-nav","type":"changed","scope":"website","summary":"Phase 17.B.4 — unified `WebsiteAdminNav` pill bar wired into every /saas/website/* route. Discovery for all 15 sub-surfaces.","body":"The UX audit's #1 finding: the website hub's toolbar listed only\n7 of 15 sub-surfaces (Blog / Globals / Media / Presets /\nTemplates / Sections / Inventory all missing). An operator\nlanded on `/saas/website/media` had no way to reach Presets\nwithout typing the URL.\n\nThis commit introduces a single shared **`WebsiteAdminNav`**\ncomponent:\n\n- Renders 15 horizontal pills covering every operator-editable\n  sub-route.\n- Per-entry permission gate so operators only see entries they\n  can read (each pill checks `me.principal.permissions` for the\n  matching `platform:website:*` key).\n- Active route highlights with primary fill.\n- `overflow-x-auto` on narrow viewports — operators scroll the\n  bar instead of wrapping or clipping.\n- Active entry is passed as a prop by the route so the\n  highlighting is explicit (no `useLocation`-based detection\n  needed; cleaner).\n\nWired into every `/saas/website/*` surface in this commit:\nhub (index) · inventory · pending · scheduled · translations ·\nredirects · collisions · archived · globals · templates ·\nsections · presets · blog · media · settings (15 total).\n\nEvery route additionally drops its ad-hoc \"← Back to website\"\nlink — the unified nav covers it; \"Pages\" pill returns to the\nhub.\n\nPure UI; no schema, no actions, no test changes; 280 / 18 tests\nstill green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-02-website-unified-admin-nav.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6fd83798-d515-4959-86c8-36f3ea90be5f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"clients-deal-won-conversion","type":"fixed","scope":"clients","summary":"Winning a deal now creates/promotes its client on every path, not just the web UI.","body":"When a deal is won, the linked company is converted to a client (or a fresh\nclient is spawned) by a server-side event subscriber listening for\n`crm.deal.won`. Previously this conversion only ran when the deal was won from\nthe web deals board — winning a deal via the AI agent, the API, or the CLI left\nthe client uncreated. Now every path produces the same result. The conversion\nis idempotent, so re-winning or re-delivery is a safe no-op.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-clients-deal-won-conversion.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7f170533-3daf-49f2-8e99-f4dd8570d207","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-activity-timeline-interactive","type":"added","scope":"crm","summary":"Log, complete, and delete activities right from a record's timeline.","body":"The activity timeline on deal, contact, and lead pages is now interactive: log a\nnote / call / meeting / task inline from the composer at the top, mark a task\ncomplete, or delete an entry — all without leaving the record. Each change\nrefreshes the timeline immediately.\n\nBacked by two new actions, `crm.activity.update` (edit / reschedule / cancel) and\n`crm.activity.delete` (soft-delete, audit-preserving), which also make activity\nediting available to the AI agent, API, and CLI — closing the gap where the\n`crm:activity:update` / `:delete` permissions existed but no action used them.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-activity-timeline-interactive.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6b7fef15-6b76-4552-82aa-c64c2e52ae6a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contact-lead-detail-pages","type":"added","scope":"crm","summary":"Contacts and leads now open full detail pages — properties, activity timeline, and conversation.","body":"Opening a contact or a lead now shows a real record page (matching the new deal\npage) instead of the old placeholder: a **Details** panel (email, phone, title,\ncompany, source, owner, score for leads), an **Activity** timeline of everything\nlogged against the record, the **Conversation** thread, and a **Related** sidebar\n(company / converted-contact links + tasks).\n\nBacked by new `crm.contact.get` and `crm.lead.get` single-record actions and a\nshared activity-timeline component, all built on the reusable record shell — so\nevery CRM entity now has a consistent, complete detail view.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-contact-lead-detail-pages.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a1947349-a8d2-4e15-b570-aa5b4fdad2b4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-restore-and-company-perms","type":"added","scope":"crm","summary":"Deleted deals, leads, and companies can now be restored, and company edit/delete permissions work as cataloged.","body":"Soft-deleted records can now be undone: `crm.deal.restore`, `crm.lead.restore`,\nand `crm.company.restore` clear the deletion (the compensation path that contacts\nalready had). Deleting a company now also emits a `crm.company.deleted` event so\ndownstream modules can react.\n\nPermission fix: the granular **Edit companies** (`crm:company:update`) and\n**Delete companies** (`crm:company:delete`) permissions are now honored by the\ncompany actions. Previously they appeared in the role editor but did nothing —\nonly the broader \"manage\" umbrella worked. A role granted just edit-or-delete on\ncompanies now behaves as expected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-restore-and-company-perms.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"42891410-910e-4aaf-ab8d-1b9ae8c88109","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contact-portal-user-provisioning","type":"added","scope":"crm","summary":"A CRM contact can now be invited as a client-portal user — an external login scoped to their company.","body":"Lays the foundation for the client portal: a CRM contact can be turned into an\nexternal **client-portal user** (`users.type = 'client'`, a `client`-role\nmembership) that is scoped, within the tenant org, to their own client company.\n\n- New action `crm.contact.invite_portal_user` — provisions a login via the\n  contact's email (linking an existing account if one already uses that email),\n  or maps an explicit existing user. Returns an invite URL + short code when an\n  invitation is pending, mirroring the proven HRM \"create user\" flow.\n- New action `crm.contact.revoke_portal_access` (dangerous) — clears the link,\n  cancels a pending invite, and suspends the client membership (reversible).\n- New permission `crm:contact:manage_portal_access`, held by owner/admin,\n  managers, and the sales functional roles.\n- `crm_contacts` gains `user_id` + `pending_invitation_id` link columns; the\n  linker back-fills the link and marks the user `client` on invite acceptance.\n- A client-portal invitee never becomes a phantom employee — the HRM\n  auto-create-employee linker now skips `role = 'client'` invitations.\n- Contacts UI: the create sheet offers \"Invite this contact to the client\n  portal\" (with a copyable invite link + code on success), and the edit sheet\n  gains a Portal access panel to invite / re-send / revoke.\n- Client detail → Contacts tab now shows each contact's portal status and lets\n  you invite / re-send / revoke per contact; the \"Add contact\" dialog there can\n  provision portal access in the same step. The client read projection exposes\n  `portalStatus` per contact.\n\nInternally, the 3-branch user-provisioning logic is now a shared\n`provisionUserAndMembership` helper in `@helios/auth` (HRM consumes it too).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-contact-portal-user-provisioning.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a8240f2f-6d9e-4964-bcab-ba06f5a51444","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contacts-bulk-edit","type":"added","scope":"crm","summary":"Select multiple contacts and set their lifecycle stage or status in one action.","body":"Select any number of contacts in the list and the bulk bar now offers **Set Stage**\nand **Set Status** menus alongside the existing delete — re-stage a batch of leads or\narchive a group in a single click. The rows update instantly (optimistic) and\nreconcile with the server.\n\nThis is a new reusable bulk-field-edit capability on the shared data grid, so other\nmodules' tables can opt into the same \"set a field across the selection\" UX.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-contacts-bulk-edit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"28ec48a9-b71c-4b21-9414-0bc14b9a0b11","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contacts-inline-edit","type":"added","scope":"crm","summary":"Edit a contact's title and lifecycle stage inline from the contacts table, with instant optimistic save.","body":"The contacts list is now editable in place: click a contact's **Title** to type a\nnew one, or click its **Stage** to pick a lifecycle stage from a menu — no need to\nopen the edit panel for a one-field change. Edits save instantly and roll back with\nan error toast if the server rejects them, and respect your permissions (read-only\nviewers see plain values).\n\nThis is built on a new reusable inline-edit grid primitive (UI-1, DataGrid v2) and\nthe shared optimistic-mutation foundation, so the same fast edit-in-place\nexperience will roll out across the app's other tables next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-contacts-inline-edit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ae5094fb-c7b9-4815-8852-ddafd8787c12","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5b-batch4","type":"changed","scope":"web","summary":"Email-suppression and catalog create/edit dialogs now use the unified Form stack.","body":"Form-polish plan, Phase 5 (settings dialogs batch): the email-suppression add dialog and\nall four catalog editors (simple catalogs, status catalogs, theme presets, AI starter\nprompts) now use the unified `useAppForm` + `Form` stack with Zod validation and inline\nerrors. These were button-`onClick` modals; the dialog bodies are now wrapped in `<Form>`\nwith `FormSubmit`. Row deletes, system-row slug locks, sort-order parsing, and upsert\npayloads are all preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.827Z","updatedAt":"2026-06-07T17:58:56.827Z"},{"id":"fd1274b5-b97e-4176-a1e5-38b94a500e69","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-detail-page","type":"added","scope":"crm","summary":"Deals now open a full detail page — properties, activity timeline, conversation, and related records.","body":"Opening a deal now shows a real record page in place of the old placeholder: a\n**Details** panel (amount, stage, probability, close date, owner), an **Activity**\ntimeline of everything logged against the deal, the deal **Conversation**, and a\n**Related** panel linking the company, contact, and tasks.\n\nIt's built on a new reusable three-column record shell (UI-5) — header, properties,\ntabbed timeline, and an associations sidebar — that the contact, lead, and other\nmodules' detail pages will adopt next, plus a new `crm.deal.get` action for\nsingle-record fetches.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-deal-detail-page.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4d9ce9c5-5c80-41ff-b86d-94ee2e41caec","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-won-lost-events","type":"fixed","scope":"crm","summary":"Won and lost deals now emit dedicated events and send the correct closing email.","body":"Closing a deal as won or lost now emits dedicated `crm.deal.won` / `crm.deal.lost`\ndomain events (in addition to the generic stage-change event), so webhooks and\nautomations can subscribe to \"deal closed\" directly.\n\nThe lost-deal reflection email now actually sends — previously a deal moved to\nlost only triggered the generic \"stage changed\" notification and the dedicated\nlost template never fired. Won and lost deals each send exactly one closing email\n(the generic stage-change email is suppressed for terminal stages, so owners no\nlonger get a duplicate).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-deal-won-lost-events.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8931cf1c-a3c8-4f86-865c-add4a50f4833","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deals-kanban-drag","type":"added","scope":"crm","summary":"Drag deals between pipeline stages on the board, with a reason prompt when dropping on Lost.","body":"The deals board is now drag-and-drop: grab a card and drop it on another stage\ncolumn to move the deal. The move applies instantly (optimistic) and reconciles\nwith the server; dropping on **Lost** prompts for a reason first. The per-card\nstage menu stays for keyboard users, and each column header still shows its deal\ncount and value.\n\nUnder the hood this is a new reusable kanban engine (UI-3) — columns, drag-to-\ncolumn, aggregates, and empty/loading states — that other modules' pipelines\n(recruitment, projects) can adopt with their own cards.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-deals-kanban-drag.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e6b2c2c8-6be9-498a-923f-a05e78e63fdd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deals-optimistic-stage-move","type":"performance","scope":"crm","summary":"Moving a deal on the board now applies instantly and rolls back automatically if the server rejects it.","body":"Dragging or moving a deal between pipeline stages now updates the board\nimmediately instead of waiting for the server round-trip, then quietly\nreconciles with the saved result. If the move fails (or needs re-verification),\nthe card snaps back to where it was and an error toast explains why — no more\nsilent failures.\n\nUnder the hood this is a new shared optimistic-mutation foundation (UI-6) that\nthe rest of the app's lists and boards will adopt, so the whole product gains the\nsame instant feel over time.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-deals-optimistic-stage-move.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"c323aaac-360a-4e8d-a590-fc86079f5d38","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-overview-metrics","type":"fixed","scope":"crm","summary":"The CRM dashboard now shows real org-wide totals instead of capping every count at 5.","body":"The CRM overview's KPI tiles (Leads, Contacts, Companies, Activities, Open\npipeline) previously showed at most **5**, because they counted a small preview\nfetch rather than the real totals. They now reflect true org-wide counts, and the\nopen-pipeline figure is summed server-side and bucketed by currency (never mixing\ncurrencies into one number).\n\nThis is powered by a new read-only `crm.metrics.overview` action that computes the\ncounts and pipeline value in SQL — accurate at any volume and available to the AI\nagent and API, not just the dashboard.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-crm-overview-metrics.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8a92077f-751a-464d-b6d2-bdb86f8aadfe","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-dunning-email-honesty","type":"fixed","scope":"payments","summary":"The failed-payment email no longer promises an automatic retry that never happens.","body":"The dunning email sent on a failed payment told customers \"We'll try again\nautomatically on {date}.\" The automatic-recharge engine is observe-only today\n(it lands with subscription billing in a later phase), so that retry never\nactually ran — leaving customers waiting on a promise the system couldn't keep.\nThe email now leads with the real recovery path: update your payment method via\nthe self-service portal link. The next-retry date is dropped from the copy.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-dunning-email-honesty.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2add6935-e025-4be1-b846-6fa710fc9d6a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-portal-error-feedback","type":"fixed","scope":"payments","summary":"The customer payment portal now shows an error when setting a default or removing a saved method fails.","body":"In the self-service payment portal, \"Make default\" and \"Detach\" had no error\nhandling — if the action failed (expired link, network blip, a method already\nremoved), nothing happened on screen and the customer was left guessing. Both\nnow surface a dismissible inline error, matching the checkout page's pattern.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-portal-error-feedback.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d3b63e0f-1c62-472c-9255-5375c1094a48","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-provider-enable-disable","type":"added","scope":"payments","summary":"Admins can now enable or disable a payment provider without deleting it; disabled providers stop receiving charges.","body":"The payments admin (both a tenant's Settings → Payments and the root\nSaas → Platform payments console, which share the same surface) gains an\nEnable/Disable control on each connected provider — the soft alternative to\ndeleting it when you want to take a gateway out of rotation but keep its\nconfiguration.\n\nThis also closes a correctness gap: the routing engine previously ignored a\nprovider's status entirely, so a provider marked disabled would still be\nselected for charges. Routing now skips operator-disabled (and paused)\nproviders on both the rule-match path and the default fallback. A `failing`\nstatus (an advisory health signal) stays routable, and unknown statuses fail\nopen, so a stray value can never silently halt all payments.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-provider-enable-disable.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6369d428-a590-4ab6-8e79-7a9bc25c64ff","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-routing-dialog-disabled-hint","type":"changed","scope":"payments","summary":"The routing-rule provider dropdown now marks disabled providers so you don't target one by mistake.","body":"When adding or editing a routing rule, the provider dropdown listed disabled\nand paused providers with no indication — so an admin could point a rule at a\nprovider that won't receive charges. Each such option is now labelled\n\"— disabled (won't receive charges)\", matching the disabled badge in the rules\ntable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-routing-dialog-disabled-hint.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6f197783-342c-4733-b0b8-c8694a86b6fb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-routing-disabled-provider-flag","type":"added","scope":"payments","summary":"Routing rules now flag when their target payment provider is disabled, so it's clear why charges fall through.","body":"When a provider is disabled, the routing engine skips it and charges fall\nthrough to the default — but the routing rules table still listed the rule\nwith no hint the target was off, which looked like a bug. Each rule whose\nprovider is disabled or paused now shows a \"provider disabled/paused\" badge,\nso admins immediately understand why that rule isn't taking effect.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-routing-disabled-provider-flag.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"cdad5c8c-0cf6-49e2-aeb6-255a68f6f804","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-webhook-dedup-fallback-3more","type":"fixed","scope":"payments","summary":"Midtrans, Mercado Pago and dLocal webhooks no longer drop events that arrive without a provider id.","body":"Extends the Square/Cashfree dedup fix to three more adapters. When a Midtrans\nnotification lacked a `transaction_id`, a Mercado Pago IPN lacked `data.id`, or\na dLocal event lacked an `id`, the dedup key collapsed to an empty string —\nso distinct events all collided on the `(provider_id, provider_event_id)`\nunique index and only the first was processed. Each adapter now falls back to\na stable secondary key (Midtrans → `order_id`, then `(status, time)`; Mercado\nPago → `(type, action, date)`; dLocal → `(status, created_date)`) that stays\nconstant across provider retries but distinct across events.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-webhook-dedup-fallback-3more.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a1e5ee63-66ab-408f-9d61-ac086e4f5fa1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-webhook-dedup-fallback","type":"fixed","scope":"payments","summary":"Square and Cashfree webhooks no longer drop events that arrive without a provider event id.","body":"When a Square webhook arrived without an `event_id`, or a Cashfree webhook\nwithout a `cf_payment_id` / `order_id`, the dedup key collapsed to an empty\nstring (Square) or the bare event type (Cashfree). Every such event then\ncollided on the `(provider_id, provider_event_id)` unique index, so only the\nfirst one was ever processed and the rest were silently discarded as\nduplicates. Both adapters now fall back to `(type, timestamp)` — stable across\nprovider retries of the same event, but distinct across different events — so\nno event is lost.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-payments-webhook-dedup-fallback.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"13d924d4-db28-4140-b9a2-e1f8e7ad0bf8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-find-and-audit","type":"added","scope":"website","summary":"Phase 17.D — `/saas/website/find` cross-page search route + `/saas/website/audit` broken-internal-link scanner.","body":"Two new admin routes covering Phase 17.D's bulk-content tools.\n\n### `/saas/website/find`\n\nThin admin UI over the existing `website.page.search` action. The\npage-list bar already supports debounced search within a single\nview; this route is the \"where did I use this phrase across the\nwhole site?\" workflow. Bookmarks (URL-persists `?q`, `?kind`,\n`?language`, `?includeArchived`), Postgres `websearch_to_tsquery`\nbacking (so quoted phrases, OR, and negation all work), and\nclick-throughs go straight to the editor.\n\nStops short of bulk replace — bulk text replacement across the\nJSONB sections is destructive and structurally risky (e.g.\nreplacing inside a `code` block's content). That half is\ndeferred to a Phase 17.D follow-up with the safety guards.\n\n### `/saas/website/audit` + `website.audit.scan_internal_links`\n\nNew read-only action that walks every published page's section\nJSON, extracts every `href` / `url` value, and flags those that\npoint to internal paths whose destination page does not exist,\nis archived, or is still in draft. External (`http(s):`),\n`mailto:`, `tel:`, fragment-only, and kind-listing\n(`/blog`, `/integration`, etc.) hrefs are deliberately skipped —\nthe scanner's job is the CMS-internal link graph, not external\nuptime.\n\nPer-finding payload: source page (id + kind + slug + language +\ntitle), section index + type, the offending href, and one of\nfive reasons:\n\n- `page_not_found` — no matching `(kind, slug, language)` row\n- `page_archived` — destination soft-deleted\n- `page_draft` — destination still pre-publish\n- `unknown_kind` — first path segment doesn't match any\n  registered kind\n- `invalid_path` — slug contains whitespace\n\nCross-language fallback: when the source page is `es` and the\ndestination is only published in `en`, the link counts as\nresolved (mirrors the public renderer's behaviour, which\nfallback-routes to `en`).\n\nAdmin UI groups findings per page, links each to the editor,\nexposes a `?includeDrafts` filter, and ships a Re-scan button\nwith optimistic toast. 5 happy-path / boundary tests added.\n\n288 / 19 website tests green (+5 new); web + marketing\ntypecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-website-find-and-audit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ae5511ca-db22-42e9-8bac-6a468bd6bcb0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-four-orphan-blocks","type":"added","scope":"website","summary":"Phase 17.F — wires 4 existing-but-orphan block components into the CMS dispatcher (section_lede / code_tabs / manifesto / dual_cta).","body":"The marketing site's `apps/marketing/src/components/blocks/`\ndirectory has 41 components but the CMS dispatcher only knew\nabout 14 (now 26 after Phase 17.C). The remaining 27 were\npage-template-only — operators couldn't drop them.\n\nThe gap-analysis spec mapped 17 of those to either new CMS\nsection types or variants of existing ones. This commit picks\nthe 4 highest-value generic ones to ship now; the rest are\neither page-specific (won't earn a generic schema) or already\ncovered by Phase 17.C's set.\n\n**Newly droppable in the visual composer:**\n\n- `section_lede` — eyebrow + heading + body opener with three\n  layout variants (standard / statement / aside). Different from\n  the `eyebrow` block (which is just the kicker line) — composes\n  the full intro. Wraps the existing `SectionLede` component.\n- `code_tabs` — multi-language code switcher (cURL / TS / Python\n  side-by-side, click-to-switch). Different from the single-\n  snippet `code` block from 17.C. Wraps `CodeTabs`.\n- `manifesto` — editorial \"why we built this\" note with optional\n  heading + body + signature + dateline. Wraps `ManifestoNote`\n  with operator body overlay.\n- `dual_cta` — two equal-weight CTA cards side-by-side\n  (\"Try it free\" / \"Talk to sales\"). Different from `cta_footer`\n  (one primary + optional secondary, full-bleed band). Inline\n  renderer; no existing-component wrap needed.\n\nEnd-to-end as usual:\n- Schema in `modules/website/src/schemas/sections.ts` + matching\n  `SectionTypeSchema` + `BlockTypeSchema` whitelist entries.\n- Renderer cases in `apps/marketing/src/components/cms/website-\n  page-renderer.tsx`; new `SectionLede` / `ManifestoNote` exports\n  added to the blocks barrel.\n- Editor catalog: BlockType union + ALL_BLOCK_TYPES +\n  BLOCK_LABELS + BLOCK_META + defaultSection() factories all\n  extended.\n\n288 / 19 website tests green. No schema migration — sections\nare JSONB. Marketing typecheck clean (180 files).\n\nSpec defers `bento_grid`, `feature_spotlight`, `closing_call`\nand the rest of the 17 — those components are heavily page-\nspecific (hardcoded module slugs / fixed visual scrollers /\nbespoke art slots) and don't earn a generic operator-droppable\nschema. They stay available to the page-template-only path\nthat's been there since Phase 6.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-website-four-orphan-blocks.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"40f1ea8c-b747-46ec-a138-c7510501eb49","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-revision-diff","type":"added","scope":"website","summary":"Phase 17.B.6 — Diff button on every revision row + dialog showing exactly what Restore would change (title / description / tags / sections / meta).","body":"The revision panel previously surfaced only metadata (title,\nstatus, snapshotAt, optional note) — no way to see what content\nRestore would actually replace. Reviewers had to either trust\nthe snapshot date blindly or scroll through the editor after\nrestoring, which is reversible but operationally awkward.\n\nThis commit adds:\n\n**Action: `website.page.get_revision`** (read, `:read`-gated).\nReturns the full revision payload (title + description +\nsections + meta + tags + status + note). Returns `not_found`\nwhen the revision doesn't belong to the caller's org or the\nnamed page. Three tests: happy path, not_found, policy-denied.\n\n**Component: `RevisionDiffDialog`**. Pairs the revision payload\nwith the live editor's snapshot and renders a per-field diff:\n\n- **Title** + **Description**: side-by-side text diff.\n- **Tags**: set diff with added (green) / removed (red, struck-\n  through) / unchanged chips.\n- **Sections**: per-index added / removed / changed badges. A\n  changed section shows a +/- line diff of its JSON (capped at\n  12 lines so a huge block doesn't blow up the dialog).\n- **Meta**: key-level diff with before/after JSON side-by-side.\n\nEdge case: when nothing changed in the editable fields, the\ndialog renders an empty-state explaining \"Restore would be a\nno-op for these fields\" so the reviewer doesn't conclude the\ndialog is broken.\n\n**Editor surface**: every revision row gets a new \"Diff\" button\nnext to Restore. The diff dialog also carries a \"Restore this\nrevision\" button that defers to the existing confirm flow — so\na Restore stays intentional even after the reviewer's seen the\ndiff.\n\n283 / 18 website tests green (+3 new).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-website-revision-diff.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"36e89cab-8327-4ad9-986f-c41745c9cdbe","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-sitemap-ssr","type":"added","scope":"marketing","summary":"Phase 17.E.1 — SSR `/sitemap.xml` + `/sitemap-index.xml` routes pulling every published CMS page + the evergreen static routes.","body":"`robots.txt` had pointed crawlers at `/sitemap-index.xml` since\nPhase 1, but the actual sitemap routes were never landed. Google's\nSearch Console rejected the broken sitemap-index reference, and\nnew pages dropped out of the search index until manually\nre-submitted. The Phase 17.D gap audit caught the silent 404.\n\nThis commit lands the missing routes as Astro SSR endpoints (so\nthey always reflect live CMS state, not the deploy-time snapshot):\n\n**`/sitemap-index.xml`** — small wrapper pointing crawlers at the\nsingle `/sitemap.xml` shard. Future per-kind / per-language\nshards land here when one sitemap pushes the 50k-URL Google\nlimit (the marketing site stays well under that for years).\n\n**`/sitemap.xml`** — per-deployment sitemap built at request time:\n- Walks all 8 CMS page kinds via the shared `listPages()` helper\n  (re-uses the SWR KV cache).\n- Pins ~16 evergreen static routes (home, /pricing, /blog, /careers,\n  /about, etc.) so a fresh deployment has a usable sitemap even\n  before CMS rows exist.\n- Drops any row with `noindex: true`.\n- `lastmod` from `updatedAt` (CMS) or the request timestamp\n  (static).\n- Dedupes on URL so CMS-managed + static collisions resolve to a\n  single entry per URL.\n- Deliberately omits `<changefreq>` and `<priority>` — Google\n  ignores them; bytes without value.\n\nWhite-label-safe: `loadBranding({ request, kv })` produces the\nhost-correct `marketingUrl` so each operator's deployment serves\nURLs prefixed by their own domain, not heliosworks.com.\n\n`Cache-Control: public, max-age=3600, s-maxage=3600` for both\nroutes — sitemap stays fresh enough for incremental indexing\nwithout overwhelming the API.\n\nHreflang annotations are deferred to a follow-up. The marketing\nruntime's `CmsPageSummary` type doesn't yet carry per-page\n`language` (single-language deployments are unaffected); plumbing\nthat through is a small but distinct change.\n\nMarketing typecheck clean (180 / 180 files).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-website-sitemap-ssr.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f65b6f81-e033-4d9b-b84a-71cdddd01871","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-url-persist-filters","type":"changed","scope":"website","summary":"Phase 17.B.5 — list filters on every /saas/website list route now live in the URL via `validateSearch`. Bookmarks + share-links + browser-back survive a refresh.","body":"The `.claude/rules/tanstack.md` rule mandates that every list view\nwith filters / pagination URL-persist its state via TanStack\nRouter's `validateSearch`. The /saas/website surfaces were the\nlast big holdout — every filter was `useState`, so bookmarking a\n\"Drafts in Spanish, kind=page\" view + opening it in a new tab\nlanded on the empty default.\n\nWired across 6 list routes:\n\n- `/saas/website` — `?kind`, `?status`, `?language`, `?q` (the\n  full-text search; debounced 300 ms before the URL write).\n- `/saas/website/archived` — `?kind`.\n- `/saas/website/templates` — `?kind`, `?status`.\n- `/saas/website/sections` — `?category`, `?status`.\n- `/saas/website/redirects` — `?q` (debounced 300 ms), `?filter`\n  (all / enabled / disabled).\n- `/saas/website/translations` — `?kind`, `?q` (debounced 300 ms).\n\nEvery `validateSearch` strictly narrows the inbound `Record<string,\nunknown>` so an attacker can't inject arbitrary values via the\nURL. Empty filters are encoded as `undefined` (not the empty\nstring) so the URL stays clean.\n\nEach route uses `replace: true` on the navigate so a stray click\nthrough 5 filter values doesn't pollute the history stack — the\nbrowser back button skips straight back to the previous page,\nwhich is what operators expect.\n\nPure UI; no schema, no actions; 280 / 18 website tests green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-03-website-url-persist-filters.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"37ce6ee2-be30-40f8-aaf1-ed1bd0ed178d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"action-call-timeout","type":"fixed","scope":"web","summary":"Action calls now time out after 30s instead of hanging forever — a stalled save (e.g. Platform → Branding) surfaces a clear \"timed out, try again\" error rather than a button stuck at \"Saving…\".","body":"`callAction` (the client wrapper every mutation uses) had **no\ntimeout** — it `await`ed `fetch` with no deadline. So if a request ever\nstalled server-side (a slow/locked DB, a stuck audit-log insert that\nruns before the response is sent, a dropped connection), the calling\nmutation stayed `pending` forever and its submit button spun at\n\"Saving…\" with zero feedback. This was reported on Platform → Branding,\nbut it could affect any save.\n\nAdded a default 30-second client timeout that composes with any\ncaller-supplied `AbortSignal`. On timeout the request aborts and throws\na `dependency_failed` error (\"The request timed out after 30s — the\nserver may be busy, please try again\"), which the mutation's existing\n`onError` surfaces as a toast and which resets the button — so the UI\ncan never get stuck at \"Saving…\" again, and the operator can retry.\n\nTunable per call via the new `timeoutMs` option (pass a larger value for\ngenuinely long operations like big CSV exports, or `0` to disable). A\ntimeout caused by a transient server stall is now retryable instead of\nfatal-looking.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-action-call-timeout.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"813d5801-c68c-4f80-a65a-bbca7e104371","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ai-v3-bet-3-phase-1-streaming-provider","type":"added","scope":"ai","summary":"V3 Bet 3 Phase 1 — `ChatProvider` interface gains an optional `stream()` method; Anthropic adapter implements it natively.","body":"V3 Bet 3 from [`docs/chat/V3_BET_3_STREAMING_AI.md`](../../docs/chat/V3_BET_3_STREAMING_AI.md) — Streaming AI replies. **Phase 1 of 5** — the foundation everything else builds on.\n\nThis commit lands **only** the provider layer. The remaining phases (DB migration, realtime envelope, streaming action, React `<StreamingMessageItem>`) layer on top of the work this commit makes possible.\n\n**What lands:**\n\n`ChatProvider` interface (`packages/ai/src/providers/types.ts`):\n- New `ChatStreamChunk` union type with kinds:\n  - `'token'` — `{ delta: string }` — a text delta to append\n  - `'tool_use_start'` — `{ id, name }` — a tool invocation began\n  - `'tool_use_input_delta'` — `{ id, partial }` — partial JSON for the tool's input\n  - `'tool_use_end'` — `{ id }` — tool invocation completed\n  - `'done'` — `{ stopReason, usage? }` — the terminal chunk\n- New optional `stream(req, signal?): AsyncIterable<ChatStreamChunk>` method on `ChatProvider`\n- New optional `streaming: boolean` field on `ProviderDescriptor` so callers can discover capability without try-catching\n\nAnthropic adapter (`packages/ai/src/providers/anthropic.ts`):\n- `descriptor.streaming = true`\n- New `stream(req, signal?)` impl using `client.messages.stream(...)` (Anthropic SDK native streaming)\n- Translates the SDK's event stream (`content_block_start` / `content_block_delta` / `content_block_stop` / `message_stop`) into the provider-neutral `ChatStreamChunk` union\n- Tool-use input streamed as partial-JSON deltas tagged with the tool-use id\n- Cancellation: `AbortSignal` threaded through; when aborted, the iterable terminates cleanly without throwing\n\n**What's NOT in this commit (Phases 2–5):**\n\n- DB migration adding `chat_messages.is_streaming` + `streaming_completed_at` — Phase 2\n- New realtime envelope `message.streaming` carrying token deltas — Phase 4\n- New action `chat.ai.thread.stream_post` exposing an SSE/ndjson HTTP response — Phase 3\n- React `<StreamingMessageItem>` consuming the stream + rendering tokens-as-they-arrive — Phase 5\n- Cancellation action `chat.ai.thread.cancel` — Phase 3\n- Other provider impls (Gemini, OpenAI-compatible) — Phase 6 (optional; they fall back to non-streaming `chat()` when their adapters don't implement `stream`)\n\nEach subsequent phase is independently shippable thanks to the optional shape of `stream()` — callers that don't yet use streaming see no behavior change.\n\n**Verification:** `@helios/ai` typecheck clean; existing 9/9 ai tests pass; no consumer changes required.\n\n**V3 progress:** 6.2 / 8 bets in main (1 Cmd+K, 2 dense inbox, 4-lite, 5 huddle recap, 6 fluid typography, 8 split-view, **3 phase 1**). Remaining work for full Bet 3 is ~2 days; Bet 7 (voice notes) is the last unstarted bet at ~3 days.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-ai-v3-bet-3-phase-1-streaming-provider.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f3836a83-4c2f-42e9-bcef-a49948cb0b46","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-ai-concierge","type":"added","scope":"clients","summary":"The client portal home now opens with an AI concierge briefing — what needs your attention plus one-tap next actions.","body":"Added an AI concierge to the client portal (AI-1). The portal home now leads with\na friendly, client-scoped briefing (`clients.portal.ai_brief`): a greeting, a\none-line headline of what's pending, a short summary, and verb-led suggested\nactions — \"Review your 2 open invoices\", \"Respond to 1 quote\" — that link straight\nto the relevant page. It's deterministic and action-only (it composes the existing\nscoped portal actions rather than introducing a separate AI data path), inherits\nthe client's own scope, and respects each surface's per-contact visibility, so a\nclient only ever sees prompts about their own account.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-ai-concierge.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fdbfb0d9-4b90-4774-b431-1e6c16200931","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-pay-pages-branding","type":"fixed","scope":"payments","summary":"Public payment pages now show the operator's brand instead of generic, unbranded chrome.","body":"The standalone payment pages (`/pay/c` checkout, `/pay/return`, `/pay/portal`)\nrender outside the app shell, so they never picked up the operator's branding —\nbuyers saw generic, unbranded chrome, leaking the white-label. They now show\nthe operator's logo and app name (from the public branding settings) in a\nheader. An unbranded deployment shows no header rather than a placeholder, and\nno brand string is hard-coded.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-pay-pages-branding.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ecabad8d-bc9e-4ef7-bef4-0a3b53fb03e7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-drag-fix-bypass-react-state","type":"fixed","scope":"chat","summary":"Channel drag works again — drag-source dim bypasses React state entirely via direct DOM mutation.","body":"The user reported \"can't drag channel\" again after the round-20\nqueueMicrotask deferral. Symptom: cursor changes to \"grab\" on\nmousedown, but the drag never starts when the user moves the\nmouse — no ghost is captured, no drop zones light up.\n\n**Root cause:**\n\nEven with `queueMicrotask`, React state updates inside the\ndragstart event chain triggered reconciliation that touched\nthe dragged element's DOM tree (opacity change via `isDragging`\nstate, sibling mount via `setDraggingChannelId` parent state).\nChromium's drag-image capture is sensitive to source DOM\nmutations DURING the dragstart task — even microtask-deferred\nupdates fired before the browser locked in the ghost.\n\n**Fix:**\n\nBypass React entirely for the drag-source visual. The drag-source\ndim now uses **direct DOM mutation** via a ref:\n\n```ts\nonDragStart={(e) => {\n  // ... setData, effectAllowed ...\n  if (rowRef.current) rowRef.current.style.opacity = '0.4';\n  // Parent state update deferred to a macrotask, AFTER the\n  // browser has captured the ghost.\n  setTimeout(() => {\n    onDragSourceChange?.(channel.id);\n  }, 0);\n}}\nonDragEnd={() => {\n  if (rowRef.current) rowRef.current.style.opacity = '';\n  onDragSourceChange?.(null);\n}}\n```\n\nThe `rowRef` points to the `<Link>` (TanStack Router forwards\nrefs to the underlying `<a>` element via its `useLinkProps`).\nSetting `style.opacity` directly mutates the DOM without\ninvolving React — the browser sees a stable element from start\nto end of drag, and the ghost captures cleanly.\n\nThe parent state update (which mounts the \"drop to remove from\ncategory\" zone) is deferred via `setTimeout(0)` — a macrotask\nthat runs AFTER the browser has snapped the ghost and the drag\nis in progress. Adding a new sibling mid-drag doesn't break the\nin-flight operation.\n\nRemoved `isDragging` React state since it's no longer used. The\nbehavior is identical from the user's perspective — drag-source\nfades to 40%, drop zone appears for categorized channels — just\nwithout breaking the drag itself.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero touched-file errors. Manually\ntested in Chromium: drag now starts immediately on threshold,\nghost is crisp, drop zones light up as expected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-drag-fix-bypass-react-state.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5016e3f0-1e41-4f8f-a748-e7024a5cf4ff","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-86-ai-thread-source-link","type":"changed","scope":"chat","summary":"Round 86 — Ask Helios pane subtitle (channel name) becomes a clickable \"← {channel}\" link that closes the AI pane and jumps focus back to the originating channel. Was a passive static label.","body":"Surfaced by AI-thread audit gap #2 (workflow `wf_9ef41eb9-bc5`) — a softer UI-only addressment.\n\nThe audit's full ask was a source-message-reference chip showing \"based on: {message preview}\" linking back to the message that triggered the AI session. That needs server-side tracking of the trigger message (`chat_ai_threads` doesn't store one today), so the full feature is migration-gated.\n\nThe smaller-scope UI win: make the existing channel label in the pane header a clickable back-link. The AI thread session was opened FROM a channel; this gives users a one-tap path back without hunting for the X close button.\n\n### Diff\n\n```diff\n-           <span\n-             className=\"truncate uppercase font-semibold\"\n-             style={{\n-               fontSize: 'var(--text-chat-caption, 11px)',\n-               letterSpacing: '0.08em',\n-               color: 'var(--fg-faint)',\n-             }}\n-           >\n-             {channelLabel}\n-           </span>\n+           <button\n+             type=\"button\"\n+             onClick={onClose}\n+             className=\"inline-flex max-w-full items-center gap-1 truncate uppercase font-semibold transition-colors hover:text-[var(--fg-muted)] focus-visible:outline-none focus-visible:underline focus-visible:underline-offset-2\"\n+             style={{\n+               fontSize: 'var(--text-chat-caption, 11px)',\n+               letterSpacing: '0.08em',\n+               color: 'var(--fg-faint)',\n+             }}\n+             aria-label={`${tt('chat.ai_thread.asked_from', 'Asked from')} ${channelLabel}`}\n+             title={`${tt('chat.ai_thread.back_to', 'Back to')} ${channelLabel}`}\n+           >\n+             <span aria-hidden=\"true\" style={{ opacity: 0.7 }}>←</span>\n+             <span className=\"truncate\">{channelLabel}</span>\n+           </button>\n```\n\n### Design notes\n\n- **Same visual weight as before** — `fontSize: --text-chat-caption`, `letterSpacing: 0.08em`, `color: --fg-faint`. The conversion is from `<span>` → `<button>` with hover/focus enhancements, not a visual upgrade. The user sees the same subtitle UNTIL they hover.\n- **`←` arrow leading icon** at 70% opacity → reads as \"go back\" without dominating the label.\n- **Hover shifts color to `--fg-muted`** (one tier up the contrast ladder) — same pattern entity-chip uses (round 38).\n- **Focus-visible underline** — keyboard users get a subtle indicator on Tab.\n- **`title` + `aria-label`** distinguish hover vs AT: tooltip says \"Back to #design\"; screen reader announces \"Asked from #design\" (which is the relationship the audit asked us to communicate).\n- **Calls `onClose()`** — reuses the existing X-button handler. The parent (channel-view) closes the AI pane + restores route to the channel.\n\n### What we did NOT do\n\n- **The full source-message chip** with message preview + click-to-scroll-to-message. Needs `chat_ai_threads.trigger_message_id` column + a backfill for existing threads. Migration-gated.\n\n2 new i18n keys: `chat.ai_thread.asked_from`, `chat.ai_thread.back_to`.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.\n\n**Sources:** AI-thread audit gap #2 (light addressment) (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.502Z","updatedAt":"2026-06-05T01:29:12.502Z"},{"id":"86cd01ef-8683-4e2a-a126-7e5ca3a76f01","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-branching","type":"added","scope":"crm","summary":"Automations can branch — route to different steps based on conditions (true if/else paths, not just a gate).","body":"CRM automations gain **branching** — the #1 enterprise-automation capability.\nA new **branch** node routes the run down the first path whose conditions match\n(each path is a clause group + its own steps; a path with no conditions is the\ncatch-all \"else\"), then continues with the steps after the branch. This is true\nif/else/switch routing, where the existing `condition` only gated. Branch paths\nrun synchronously and don't hold a delay (long waits stay at the top level).\nAuthorable today via the API / AI / templates; a visual branch editor follows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:57.076Z","updatedAt":"2026-06-07T17:58:57.076Z"},{"id":"6a8ab72e-b9b4-4d3b-bdbc-154c436d26ec","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-drop-to-remove-from-category","type":"added","scope":"chat","summary":"Drag a categorized channel anywhere — a \"Drop to remove from category\" zone surfaces at the top of the sidebar.","body":"Closes a real UX gap: removing a channel from its category used to\nrequire either right-click → \"Remove from group\" (deeply nested\nin the channel context menu's \"Group\" submenu) or dragging the\nchannel all the way down to the often-scrolled-off **Channels**\n(uncategorized) `<nav>`. Most users never discovered either path.\n\n**What now works:**\n\nThe moment you start dragging a channel that's in a category, a\nwarning-tinted dashed drop zone appears above the categories list\nreading **\"Drop to remove from category\"**. Release on it and the\nchannel jumps back to the uncategorized **Channels** section.\n\nThe zone:\n\n- Only mounts during a drag of a categorized channel — never adds\n  visual noise in the resting state.\n- Visually \"hot\" on hover (16% accent-warning bg + 60% border)\n  vs. idle (8% / 38%) so the user gets clear drop-target feedback.\n- Uses the standard `text/x-helios-channel` dataTransfer type, so\n  it composes cleanly with the existing in-category-reorder + the\n  category section's drop-to-add zone.\n- Calls the same `chat.category.set_channel_category` action with\n  `categoryId: null` that the right-click \"Remove from group\"\n  option uses, so audit + realtime stay consistent.\n- The dragging-channel state is tracked at the sidebar root and\n  cleared on `dragend` regardless of where the drop landed — no\n  way to leave the zone stuck visible if the user aborts the drag\n  (Esc) or drops on something else.\n\nHow it's wired:\n\n- `ChannelRowItem` gains an `onDragSourceChange` callback fired\n  on `dragstart` (with the channel id) + `dragend` (with `null`).\n- The sidebar root keeps a `draggingChannelId` state + derives\n  `draggedChannelHasCategory` from the channels list.\n- The new `RemoveFromCategoryDropZone` component renders only\n  when `draggedChannelHasCategory` is true, just above the\n  categories list — directly in the user's eyeline mid-drag.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero sidebar-file errors.\n\nThe pre-existing paths (right-click \"Remove from group\" + drop on\nUncategorized `<nav>`) still work; the new zone is purely additive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-drop-to-remove-from-category.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"0fb2291f-6cb1-4c1e-8605-e79d7e415a77","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-hover-toolbar-overflow","type":"changed","scope":"chat","summary":"Message hover toolbar trims to the high-leverage actions; the rest live behind a \"More\" overflow menu (Slack/Mattermost pattern).","body":"The message hover toolbar previously surfaced **13+ inline\nbuttons** when fully expanded (quick-react × 3 + smiley + reply +\ncopy link + details + convert-to-ticket + forward + bookmark +\nremind + translate + extract + mark unread + pin + edit +\ndelete). On a busy message that meets every gate it overflowed\nthe row, and the eye couldn't pick a single primary action.\n\nSlack and Mattermost both solve this by showing **only the\nhigh-leverage actions inline** and tucking the rest behind a \"More\"\n(⋯) overflow that opens a popover menu. This round adopts the\nsame pattern.\n\n**Inline (always):**\n\n- Quick-react triple (👍 ❤️ 🎉) — hidden on own / AI / deleted\n  rows where reactions don't apply\n- Smiley → full emoji picker\n- Reply in thread → opens the side pane\n- Bookmark → toggle, kept inline because its filled / outlined\n  warning-tinted glyph carries glance-state the menu wouldn't\n- Remind me + Translate → kept inline because each owns its own\n  inline popover surface (date picker, translation result) that\n  needs an explicit anchor\n- **More (⋯)** → opens the existing `MessageContextMenu` anchored\n  just below the button\n\n**Behind the More menu:**\n\n- Copy text + Copy link to message\n- Mark unread\n- Pin / Unpin from channel\n- Forward to another channel\n- Extract tasks (chat → projects bridge)\n- Translate trigger (the inline UI still mounts via the existing\n  button; the More entry just fires `setTranslateOpen(true)`)\n- Convert to support ticket\n- Message details\n- Edit message (own)\n- Delete message (own / canModerate) — danger tint\n\n**The same MessageContextMenu handles both entry points:**\n\n- Right-click on a message → menu opens at the cursor\n- Click the inline ⋯ → menu opens just below the button via\n  `getBoundingClientRect()`\n\nBoth anchors share the new prop surface:\n`canForward / canRemindMe / canTranslate / canExtractTasks /\ncanConvertToTicket` plus the matching `onForward / onRemindMe /\nonTranslate / onExtractTasks / onConvertToTicket` callbacks. The\nexisting context-menu render already had Copy text, Copy link,\nPin, Mark unread, Edit, Delete — round 18 just extended it with\nthe rest.\n\nA `convertToTicket` helper was extracted in the parent component\nso the same code path fires whether the user reached the action\nvia the More menu or via the right-click context menu.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-18 file errors.\n\nThe result: the inline strip is **5–7 buttons** on a typical\nnon-own / non-AI / channel message (vs 13+), the scan is\nglanceable, and every previously inline action is one click away\nin the menu.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-hover-toolbar-overflow.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"60cb489e-353c-4c18-8432-41fda475ee72","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-responsiveness-round-22","type":"changed","scope":"chat","summary":"Chat module gets a focused mobile-first polish — 44 px touch targets where it matters, prevent-zoom on composer, always-visible drag-handle on touch, popover viewport clamp.","body":"User request: \"Work heavily on mobile responsiveness.\" Research, analyze, improve, polish.\n\n**Research (2026 mobile chat UX standards):**\n\n- **Touch targets**: 44 pt iOS / 48 dp Android baseline (Apple HIG + Material 3). Apps that violate this see fat-finger error rates 3-5× higher.\n- **iOS Safari auto-zoom**: any `<input>` / `[contenteditable]` with `font-size < 16px` triggers a zoom on focus, then the user has to manually pinch back out. Hard-pin to 16 px to prevent.\n- **Safe-area insets**: bottom CTAs should sit `16px + env(safe-area-inset-bottom)` above the home indicator (already shipped in `chat-layout.tsx` via `useKeyboardInset` for the soft keyboard).\n- **Swipe gestures**: Slack mobile redesign (2024 + March-2026) leans heavily on swipe-right-to-go-back / swipe-down-for-recent — Helios chat already has swipe-to-reply on message rows via `use-swipe-to-reply.ts`.\n- **Hover-state replacement**: touch has no hover. Apps either (a) auto-show hover affordances on touch (clutters layout) or (b) move them behind long-press / context menus. Helios chose (b) — the right-click context menu on messages (round 10) doubles as long-press on touch.\n\nSources:\n- [Mobile UX Design Guide 2026 — UXCam](https://uxcam.com/blog/mobile-ux/)\n- [Mobile App UX Best Practices 2026 — Forasoft](https://www.forasoft.com/blog/article/mobile-app-ux-design-best-practices)\n- [Mobile Touch Target Problem — SiteImprove](https://www.siteimprove.com/blog/motor-impairments-and-mobile-ui-the-touch-target-problem/)\n- [Slack Mobile Redesign — Slack Design](https://slack.design/articles/re-designing-slack-on-mobile/)\n\n**Audit findings:**\n\nAlready in place (no work needed):\n- `chat-layout.tsx` has `useKeyboardInset` — visual-viewport-aware composer that doesn't get covered by the soft keyboard\n- `<MessageItem>` has `use-swipe-to-reply` for the swipe-right gesture\n- Channel header action cluster already uses `hidden sm:flex` / `md:flex` patterns to hide secondary actions on narrow viewports\n- AppShell already has a mobile drawer for the sidebar — chat inherits it\n- The split-view (Bet 8, `14a2e7ed`) already hides on `<md` per its style\n- Long-press → MessageContextMenu (round 10, `3f6622c1`) gives full action access on touch via the standard `oncontextmenu` event browsers fire on long-press\n- Most popovers respect `max-w-[92vw]` / `calc(100vw-1.5rem)` patterns\n\nGaps fixed in this commit:\n\n**(1) Touch targets ≥ 44 px on intent surfaces.** `@media (pointer: coarse)` scoped to:\n- Composer toolbar buttons (`.helios-composer-toolbar button`) — bumped to 44 px hitbox, visual icon stays 14 px\n- Popover menu items + buttons (`[data-helios-chat-popover] [role='menuitem' / 'option']`, `button`) — same 44 px hitbox\n\nSidebar rows, message rows, and inline chip buttons are **NOT** bumped — their `height` is explicit and bumping `min-height` would shift the visual rhythm. The hover toolbar isn't bumped either since it's hidden on touch (long-press is the touch path).\n\n**(2) Prevent iOS auto-zoom on composer.** `@media (pointer: coarse)` forces `font-size: 16px` on `.ProseMirror` + `[contenteditable]` inside the chat surface. Without this, the V3 Bet 6 fluid `clamp()` can land the body at 15 px on narrow viewports → iOS Safari zooms → user has to manually pinch back. Hard-pinning to 16 px on touch sidesteps Safari's auto-zoom heuristic without disturbing the desktop scale.\n\n**(3) Always-visible sidebar drag-handle grip on touch.** The desktop reveal uses `opacity-0 group-hover:opacity-100` — never fires on touch, so the draggability cue is invisible. On touch, the grip pinpoints at `opacity: 0.5` always so users learn the affordance.\n\n**(4) Popover viewport clamp.** `@media (max-width: 480px)` adds `max-width: calc(100vw - 16px)` to all `[data-helios-chat-popover]` surfaces as a backstop. Most popovers already respect viewport; this catches the few that hard-code `w-[440px]` etc. for the channel-engagement / channel-decisions / channel-files popovers.\n\n**What's explicitly NOT in this round (deliberate non-goals):**\n\n- Force-showing the hover toolbar on touch — would clutter every message row. Long-press → context menu is the iOS / Android standard and matches Slack / Discord / iMessage.\n- A separate mobile-only chat layout — desktop responsive design + the AppShell's existing mobile drawer cover the case.\n- Native iOS / Android apps — the PWA standalone path already covers the install-to-home-screen use case.\n- Swipe-right-to-go-back nav — TanStack Router's history already does this via the browser's native back-swipe on iOS.\n\n**Verification:** chat 107/107 tests pass.\n\nThis round closes the *content* mobile gaps. The *layout* mobile gaps (column resizing, drawer transitions) live in the AppShell and were addressed earlier.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-responsiveness-round-22.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"2d203c01-8a31-4a53-8cb2-ca1ffb5002ef","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-23-token-scaling","type":"changed","scope":"chat","summary":"Mobile round 23 — token scaling (header 64→52 px, divider 16→10 px), continuation-row gutter trim, dynamic-viewport `dvh`, mobile message-list padding.","body":"User asked: \"Work heavily on mobile responsiveness.\" Continuation of round 22's baseline. Round 22 covered the touch surface (44 px hitboxes, prevent iOS zoom, drag-handle visibility, popover viewport clamp). This round goes after the **vertical-space budget** — every pixel reclaimed below `sm` (640 px) is a row of message content the user gets back.\n\n**Research (deeper layer):**\n\n- **Thumb zones**: 75 % of users navigate one-handed primarily with their thumb. The bottom 25–40 % of the screen is the natural-reach zone; anything above is a stretch. The composer + bottom-pinned actions already live in this zone — good.\n- **Dynamic viewport units (dvh)**: iOS Safari's address-bar collapse on scroll causes `100vh` to over-flow by ~56 px; users see a one-frame layout jiggle. `dvh` (iOS 15.4+, Android Chrome 108+, FF 101+) tracks the live viewport including dynamic chrome.\n- **Vertical-space economics on mobile**: Slack mobile uses a 48 px channel header (we had 64 px); iMessage uses 50 px; Telegram 52 px. Desktop V2 tuned for arm's-length reading at 64 px — too tall for a 667 px iPhone SE viewport.\n\nSources:\n- [Thumb-Zone Optimization — WebDesignerIndia / Medium](https://webdesignerindia.medium.com/thumb-zone-optimization-mobile-navigation-patterns-9fbc54418b81)\n- [Smashing — The Thumb Zone](https://www.smashingmagazine.com/2016/09/the-thumb-zone-designing-for-mobile-users/)\n- [Mobile-First UX: Designing for Thumbs](https://prateeksha.com/blog/mobile-first-ux-designing-for-thumbs-not-just-screens)\n- [Fix mobile keyboard overlap with dvh](https://www.franciscomoretti.com/blog/fix-mobile-keyboard-overlap-with-visualviewport)\n- [When 100vh Lies — OpenReplay](https://blog.openreplay.com/fix-100vh-mobile-viewport/)\n\n**What lands:**\n\n**(1) Mobile token overrides @ `max-width: 640px`** — `--space-chat-header: 64 → 52 px`, `--space-chat-divider: 16 → 10 px`, `--space-chat-composer-min: 44 → 40 px`. Saves ~24 px of vertical chrome per viewport — about one extra message row visible.\n\nSidebar row stays 36 px (already touch-friendly + the AppShell mobile drawer is wide enough to render channel names comfortably).\n\nScoped to viewport width, **not** `pointer: coarse` — narrow desktop viewports (split browser windows, hidden chat sidebars) benefit from the same tighter rhythm. Width is the relevant axis here.\n\n**(2) Continuation-row gutter trim @ `max-width: 640px`** — message rows where the same author posted within 5 min reserve a 40 px gutter for the avatar that the row doesn't actually render (the gutter holds a hover-revealed timestamp on desktop). On mobile the timestamp surfaces via long-press → context menu; the gutter is pure dead space. Trim to 28 px on mobile so each line of text gets +12 px back. Author rows (with the actual avatar) keep their full gutter.\n\nImplementation: new `data-msg-continuation=\"true\"` attribute on the message row + `data-msg-gutter` on the inner gutter div. CSS rule matches `[data-msg-continuation='true'] [data-msg-gutter]` on `max-width: 640px` to shrink width.\n\n**(3) Mobile message-list horizontal padding** — drops to 8 px below `sm`. Already in place at the per-component level (`px-2 sm:px-6 lg:px-8`); this is a CSS backstop for any new chat surface that forgets the responsive ladder.\n\n**(4) Dynamic viewport (`dvh`) on the chat shell** — `@supports (height: 100dvh)` + `@media (max-width: 640px)` overrides `h-full` with `100dvh` so the chat surface tracks the actual visible viewport on iOS Safari (address-bar collapse) and Android Chrome. Fallback: the existing `h-full` declaration the wrapper carries.\n\n**What's NOT in this round (separate concerns):**\n\n- Channel header action-cluster overflow — already uses `hidden md:flex` / `sm:flex` patterns to hide secondary actions on narrow viewports. Pinned + Threads + Ask AI + Voice still visible on mobile, which is the right set.\n- Composer toolbar wrapping — already has `overflow-x-auto` (round 13).\n- iOS PWA standalone polish — separate (already shipped via `html.helios-standalone` rules earlier).\n\n**Verification:** chat 107/107 tests pass. Visual sanity on a 375 × 667 px viewport: channel header now 52 px (was 64), composer 40 px min (was 44), continuation rows lose the 12 px gutter dead space, message column gutters tightened. Net effect: a 667 px iPhone SE viewport shows ~6 messages where it previously showed ~5.\n\n**Round summary (mobile work so far):**\n\n| Round | What |\n|---|---|\n| 22 (`6044a231`) | 44 px touch targets on intent surfaces, prevent iOS auto-zoom (force 16 px composer font), always-visible drag handle on touch, popover viewport clamp |\n| 23 (this) | Token scaling for narrow viewports, continuation-row gutter trim, dvh dynamic viewport, message-list padding backstop |","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-23-token-scaling.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e8217341-4ef3-4c3a-a70f-7fc5bc0f5e2f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-24-catch-up-header","type":"changed","scope":"chat","summary":"Mobile round 24 — catch-up inbox header tightens on `<sm` (hide subtitle, \"Mark all read\" goes icon-only).","body":"User asked: \"Work heavily on mobile responsiveness.\" Continuation of rounds 22–23.\n\nThis round targets the **`/chat` catch-up inbox header** — the surface a user lands on every morning. On mobile, the header packed: 36 px medallion + \"Catch-up\" title + subtitle paragraph + 3 hidden CountChips + Dense toggle + \"Mark all read\" button. Subtitle wrapped to two lines on a 375 px viewport, eating ~20 px of vertical space the user came here to read messages with.\n\n**What lands:**\n\n**(1) Catch-up subtitle hidden on `<sm`.** \"What you missed since you stepped away.\" was informational; the title alone communicates the page identity. Switched to `hidden truncate sm:block`. The CountChips already auto-hide on `<sm` (per their existing `hidden ... sm:inline-flex` class), so the right side of the header collapses cleanly to: Dense toggle + Mark all read.\n\n**(2) \"Mark all read\" goes icon-only on `<sm`.** The full label takes ~80 px of horizontal space. On mobile we drop the label and keep just the `CheckCircle` icon. `aria-label` + `title` preserve a11y and surface the action name on long-press.\n\nTogether: the header collapses from ~3 lines worth of content (header + subtitle + button-row) to a clean 52 px (round-23's mobile header height) on a phone viewport. The catch-up inbox now greets the user with a single proper header row, immediately followed by their unread sections.\n\n**What's NOT in this round (separate work):**\n\n- Channel-view header is already responsive (round 22 audit confirmed — `hidden md:flex` patterns on Files/Decisions/Snapshot buttons, `hidden sm:flex` on Files button, `hidden sm:inline` on Ask-AI label, `DensityToggle` itself gated `sm:flex`).\n- Sidebar drawer mode is owned by the AppShell.\n- Search modal uses `max-w-2xl` which shrinks to viewport-minus-gutter on mobile via the Modal primitive's responsive sizing.\n\n**Verification:** chat 107/107 tests pass.\n\n**Round summary (mobile work so far):**\n\n| Round | Hash | What |\n|---|---|---|\n| 22 | `6044a231` | 44 px touch targets, prevent iOS auto-zoom, always-visible drag handle, popover viewport clamp |\n| 23 | `ac482658` | Token scaling (header 64→52, divider 16→10, composer 44→40), `dvh` viewport, continuation-row gutter trim |\n| 24 | this | Catch-up header subtitle hide + Mark-all-read icon-only on `<sm` |","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-24-catch-up-header.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5a0be57a-67f6-4328-bc10-918d00c0567c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-25-dm-subtitle","type":"changed","scope":"chat","summary":"Mobile round 25 — DM channel-view header subtitle hidden on `<sm`; avatar status badge already signals online state.","body":"Continuation of mobile rounds 22–24. The DM channel header rendered:\n\n- Avatar (32 px) with status badge (online/offline dot)\n- Recipient name (17 px title)\n- Subtitle: \"Direct message · online\" / \"Direct message\" (11 px uppercase caption)\n\nOn a 375 px iPhone viewport the subtitle eats ~14 px of vertical space without payoff — the avatar status badge already signals the same online state, and the surrounding `/chat/$id` route makes the surface identity (\"this is a DM\") obvious. The subtitle is information you only learn AT a glance from desktop chrome.\n\n**What lands:** subtitle switches to `hidden truncate sm:block`. Desktop unchanged. Mobile gets +14 px of vertical space back, reinforcing round 23's \"every pixel counts\" budget on phone viewports.\n\n**Verification:** chat 107/107 tests pass.\n\n**Mobile work this session:**\n\n| Round | Hash | What |\n|---|---|---|\n| 22 | `6044a231` | 44 px touch targets, prevent iOS auto-zoom, always-visible drag handle, popover viewport clamp |\n| 23 | `ac482658` | Token scaling (header 64→52, divider 16→10, composer 44→40), `dvh` viewport, continuation-row gutter trim |\n| 24 | `1bc15fa1` + `773dd461` bundle | Catch-up header subtitle hide + Mark-all-read icon-only |\n| 25 | this | DM channel-view subtitle hide on `<sm` |","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-25-dm-subtitle.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"19549846-fd19-4952-82df-a459ded057c2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-26-lightbox-swipe","type":"added","scope":"chat","summary":"Mobile round 26 — image lightbox swipe-down-to-dismiss (iOS Photos pattern).","body":"Continuation of mobile rounds 22–25.\n\nThe image lightbox previously dismissed via tap-the-X / tap-outside / Esc — all functional but desktop-shaped affordances. iOS Photos established the swipe-down-to-dismiss gesture as the touch-native dismissal pattern; users now expect it across every modal image viewer.\n\n**What lands:**\n\n- `onTouchStart` / `onTouchMove` / `onTouchEnd` on the lightbox backdrop.\n- Tracking the finger's downward delta in `dragY` state. Capped at 400 px so a long swipe doesn't fling indefinitely.\n- The image's `transform: translate3d(0, ${dragY}px, 0)` follows the finger directly — the gesture feels coupled, not interpreted.\n- Backdrop opacity fades 1 → 0.5 as `dragY` grows (couples to the gesture so the user sees the dismissal happening).\n- Release threshold: 100 px. Above → close. Below → spring back via `transform 220ms cubic-bezier(0.16,1,0.3,1)`.\n- Touch-only — desktop drag doesn't trigger.\n\n**Verification:** chat 107/107 tests pass; @helios/web typecheck zero round-26 file errors.\n\n**Mobile work this session:**\n\n| Round | Hash | What |\n|---|---|---|\n| 22 | `6044a231` | 44 px touch targets, prevent iOS auto-zoom, always-visible drag handle, popover viewport clamp |\n| 23 | `ac482658` | Token scaling for narrow viewports, `dvh`, continuation-row gutter trim |\n| 24 | `1bc15fa1` + `773dd461` | Catch-up header subtitle hide + Mark-all-read icon-only |\n| 25 | `a4116d7b` | DM channel-view subtitle hide on `<sm` |\n| 26 | this | Image lightbox swipe-down-to-dismiss (iOS Photos pattern) |","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-26-lightbox-swipe.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"bcbdf34b-5b07-457e-ac61-4432eddc08a4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-45-aria-keyshortcuts","type":"changed","scope":"chat","summary":"Round 45 — three chat-global keyboard shortcuts (Cmd+K palette, Cmd+Shift+F sidebar filter, Cmd+Shift+M search modal) now expose `aria-keyshortcuts` on their visible triggers so screen-reader users hear the chord on focus.","body":"Surfaced by research:a11y Pattern 3 + WCAG 4.1.1 / 2.1.4 (Character Key Shortcuts) discoverability. Before this round, the Cmd+K palette, Cmd+Shift+F sidebar-filter focus, and Cmd+Shift+M global message-search were all wired up via top-level `useEffect` keydown handlers but were INVISIBLE to assistive tech — no `aria-keyshortcuts` anywhere meant NVDA/JAWS/VoiceOver users had no programmatic way to discover them.\n\n**Fix:** add `aria-keyshortcuts` to the three visible triggers:\n\n```diff\n  /* chat-command-palette.tsx — the palette dialog itself */\n  <div\n    role=\"dialog\"\n    aria-modal=\"true\"\n+   aria-keyshortcuts=\"Meta+K Control+K\"\n    aria-label={tt('chat.palette.aria', 'Chat command palette')}\n\n  /* chat-channels-sidebar.tsx — inline filter input */\n  <input\n    ref={filterInputRef}\n    type=\"text\"\n+   aria-keyshortcuts=\"Meta+Shift+F Control+Shift+F\"\n\n  /* chat-channels-sidebar.tsx — global message-search trigger */\n  <button\n    type=\"button\"\n+   aria-keyshortcuts=\"Meta+Shift+M Control+Shift+M\"\n```\n\n### Why both `Meta` and `Control` in the chord\n\n`aria-keyshortcuts` is per the [WAI-ARIA spec a space-separated list of chord tokens](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts), each in `Modifier+Modifier+Key` form. The existing keydown handlers fire on `(isMac ? metaKey : ctrlKey) + key` (chat-channels-sidebar.tsx:585-588), so the announcement must reflect BOTH platforms. The screen reader picks the one matching the user's OS.\n\n### What we did NOT add\n\n- `aria-keyshortcuts` on the `?` shortcut for the future Shortcuts modal — that modal doesn't exist yet (deferred to a future round).\n- The composer's local shortcuts (Ctrl+B / Ctrl+I / Ctrl+K for formatting) — those are TipTap's native handlers, and the toolbar buttons already render `<kbd>Ctrl+B</kbd>` text labels which screen readers announce as content. The `aria-keyshortcuts` would duplicate that.\n- The single-key shortcuts (R to reply, E to edit, ↑ to edit last) — WCAG 2.1.4 requires single-character shortcuts to be either remappable OR disableable (they collide with NVDA/JAWS browse-mode commands). Helios doesn't have those yet; if/when they're added, they need a settings toggle and THAT needs to land before exposing them via `aria-keyshortcuts`.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:**\n- [aria-keyshortcuts — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-keyshortcuts)\n- [WCAG 2.1.4 Character Key Shortcuts](https://www.w3.org/WAI/WCAG21/Understanding/character-key-shortcuts.html)\n- research:a11y-wcag3 Pattern 3 (workflow `wf_14d2b01a-8de`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.061Z","updatedAt":"2026-06-05T01:29:11.061Z"},{"id":"f0045310-89f0-4027-bbba-ce20014381b2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-27-huddle-bar-labels","type":"changed","scope":"chat","summary":"Mobile round 27 — huddle-bar \"Call\" / \"Huddle\" / \"Live\" labels hide on `<sm`; phone icon + pulsing dot carry the signal.","body":"Continuation of mobile rounds 22-26.\n\nThe channel header on mobile already trims most action labels (round 22 audit). Two stragglers remained in the huddle-bar:\n\n1. **Start button** — DM variant `<Call>` and channel variant `<Huddle>` rendered the icon + label inline. With the AI button hiding its label on `<sm` (round-7 polish), the huddle label was the last text label in the action cluster eating ~30 px.\n2. **Live connected pill** — when a call is active, the header shows `[• Live] [mic] [phoneslash]`. The \"Live\" word is redundant with the pulsing dot — anyone seeing an animated red dot knows the call is in progress.\n\n**What lands:**\n\n- Start button (DM + channel split-button variant): label wrapped in `<span className=\"hidden sm:inline\">`. Icon-only on `<sm`; aria-label + title preserve full a11y.\n- Live pill connected state: \"Live\" wrapped in `<span className=\"hidden text-[11px] font-medium sm:inline\">`. Pulsing dot alone signals connected state.\n\n**Verification:** chat 107/107 tests pass.\n\n**Mobile work this session:**\n\n| Round | Hash | What |\n|---|---|---|\n| 22 | `6044a231` | 44 px touch targets, prevent iOS auto-zoom, drag handle, popover clamp |\n| 23 | `ac482658` | Token scaling, `dvh`, continuation-row gutter trim |\n| 24 | `1bc15fa1` + `773dd461` | Catch-up subtitle + Mark-all-read icon-only |\n| 25 | `a4116d7b` | DM channel-view subtitle hide |\n| 26 | `0e7ae585` | Image lightbox swipe-down-to-dismiss |\n| 27 | this | Huddle-bar labels icon-only on `<sm` |","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-27-huddle-bar-labels.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a60b5d8f-1dee-404e-9e95-35919d50be3a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-28-kbd-hints-hide","type":"changed","scope":"chat","summary":"Mobile round 28 — keyboard-hint chrome (kbd glyphs in palette / search / mention popover) hides on `<sm`.","body":"Continuation of mobile rounds 22-27.\n\nThree chat surfaces footer-render keyboard hints like `↑↓ navigate · ↵ open · esc close · ⌘K toggle`:\n\n1. Cmd+K command palette (round 1)\n2. Search modal (round 19)\n3. Mention popover (round 19)\n\nTouch users don't have arrow keys or `⌘K` shortcut. The kbd-glyph chrome is desktop-only chrome; on mobile it's visual noise that takes ~24 px of vertical space inside surfaces that are already viewport-cramped.\n\n**What lands:**\n\n- All three kbd-hint footers wrapped in `<div className=\"hidden ... sm:flex\">` (or `sm:inline-flex` for the inline variant in the mention popover header).\n- Desktop unchanged.\n- Mobile gets +24 px of vertical breathing room across each affected surface.\n\n**Verification:** chat 107/107 tests pass.\n\n**Mobile work this session:**\n\n| Round | Hash | What |\n|---|---|---|\n| 22 | `6044a231` | 44 px touch targets, prevent iOS auto-zoom, drag handle, popover clamp |\n| 23 | `ac482658` | Token scaling, `dvh`, continuation-row gutter trim |\n| 24 | `1bc15fa1` + `773dd461` | Catch-up subtitle + Mark-all-read icon-only |\n| 25 | `a4116d7b` | DM channel-view subtitle hide |\n| 26 | `0e7ae585` | Image lightbox swipe-down-to-dismiss |\n| 27 | `e2466ca7` | Huddle-bar labels icon-only on `<sm` |\n| 28 | this | Kbd-hint footers hidden on `<sm` (palette, search, mention) |","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-28-kbd-hints-hide.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ad8a2f0d-2158-46e7-b92f-8674b8483cc9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-29-channel-title-token","type":"changed","scope":"chat","summary":"Mobile round 29 — non-DM channel header title migrates to `--text-chat-title` token (consistent with V2 + DM-variant + benefits from fluid clamp + density).","body":"Continuation of mobile rounds 22-28.\n\nThe non-DM channel header title was the lone holdout still using a fixed 15.5 px literal — the DM-variant title was already on `--text-chat-title` (V2 Phase 2). On a 375 px iPhone the literal was identical to the token-resolved value coincidentally; on a 32\" ultrawide, the literal stayed flat while the rest of the chat scale crept up.\n\n**What lands:**\n\n- Non-DM channel header title: `text-[15.5px]` → `fontSize: var(--text-chat-title)` + `lineHeight: 1.25` + `tracking-tight`.\n- Picks up V3 Bet 6's fluid `clamp(16px, ..., 19px)` scaling — title grows smoothly on wider viewports.\n- Picks up the Compact / Default / Cozy density toggle overrides.\n- Picks up the mobile `--space-chat-header` size adjustments (round 23).\n\nVisual delta on mobile = none (the clamp lands at 16 px, close to the previous 15.5 px). Visual delta on ultrawide = +3 px title; matches the DM-variant.\n\n**Verification:** chat 107/107 tests pass.\n\n**Mobile session summary — closing round:**\n\n| Round | Hash | What |\n|---|---|---|\n| 22 | `6044a231` | 44 px touch targets, prevent iOS auto-zoom, drag handle, popover clamp |\n| 23 | `ac482658` | Token scaling (header 64→52, divider 16→10, composer 44→40), `dvh`, gutter trim |\n| 24 | `1bc15fa1` + `773dd461` | Catch-up subtitle + Mark-all-read icon-only |\n| 25 | `a4116d7b` | DM channel-view subtitle hide |\n| 26 | `0e7ae585` | Image lightbox swipe-down-to-dismiss |\n| 27 | `e2466ca7` | Huddle-bar labels icon-only |\n| 28 | `3d29cd8a` | Kbd-hint footers hidden (palette / search / mention) |\n| 29 | this | Non-DM title → `--text-chat-title` token (consistent + fluid) |\n\nCumulative mobile improvements:\n- ~24 px vertical chrome reclaimed per viewport\n- All intent-surface buttons ≥ 44 px hitbox on touch\n- iOS Safari no longer auto-zooms on composer\n- Dynamic viewport `dvh` tracks address-bar collapse\n- Catch-up + DM headers + huddle labels collapse to icon-only on `<sm`\n- Image lightbox swipe-down-to-dismiss (iOS Photos pattern)\n- Kbd-hint chrome suppressed on touch surfaces\n- Continuation-row gutters return 12 px per text line\n- Always-visible drag-handle teaches affordance on touch","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-29-channel-title-token.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"8c082aad-0415-4e4d-8bb8-b600d17efa3d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-30-composer-formatting-collapse","type":"changed","scope":"chat","summary":"Mobile round 30 — composer formatting group (10 buttons) collapses behind an `Aa` toggle on `<sm` so action buttons (attach / mic / poll / link / AI / emoji) stay reachable without scrolling.","body":"The composer toolbar carries 16 buttons in two groups:\n\n- **Left (formatting, 10 buttons):** B / I / strike / H / link · [sep] · code / codeblock / quote / bulleted-list / numbered-list\n- **Right (actions, 6 buttons):** attach / mic / poll / link-work / AI / emoji\n\nOn a 320 px iPhone SE that overflows. The toolbar wrapper has `overflow-x-auto` so nothing is unreachable, but in practice users had to horizontally scroll to reach mic / attach — the most-used action buttons. Formatting (bold, lists, code blocks) is barely used on mobile.\n\n**What lands:**\n\n- New mobile-only `Aa` toggle button at the start of the toolbar (hidden on `sm+`).\n- The 10-button formatting group is hidden by default on `<sm` and toggled by the `Aa` button.\n- Action buttons (attach / mic / poll / link-work / AI / emoji) now sit in the visible portion of the toolbar without any horizontal scroll on every iPhone width.\n- Desktop unchanged — `Aa` toggle is `sm:hidden`; formatting group is `hidden sm:flex` (always visible on `sm+`).\n\n**State plumbing:**\n\n```tsx\nconst [mobileFormattingOpen, setMobileFormattingOpen] = useState(false);\n```\n\nState is local to the composer. Lives across the render — if you open formatting, type some bold text, then send, the next message starts with formatting still open (consistent with mobile UX conventions where toggles persist within a session).\n\n**Verification:** chat 107/107 tests pass; composer file typechecks clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-30-composer-formatting-collapse.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9573ee2a-d67a-4919-bd61-6132f6857207","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-mobile-round-31-voice-player-fit","type":"fixed","scope":"chat","summary":"Mobile round 31 — voice-message bubble shrinks to fit on iPhone SE (320 px) instead of forcing horizontal overflow with a fixed 280 px min-width.","body":"The chat voice-message bubble had a literal `minWidth: 280` style. On a 320 px iPhone SE the chat gutter leaves ~288 px for the message column; a voice note from another participant (with their avatar / name gutter pulling the bubble further right) ended up wider than its parent and forced horizontal scroll on the message row.\n\n**Fix:** swap to `minWidth: 'min(280px, 100%)'`. The bubble keeps its comfortable wide layout on any container ≥ 320 px wide, and shrinks gracefully below that — the play button, waveform, timer, speed cycler and download link continue to lay out cleanly thanks to the existing `flex` + `min-w-0` rules on the inner column.\n\nNo visual delta on desktop / iPad / iPhone 12+. iPhone SE no longer needs to scroll horizontally to see the speed-cycler / download chrome at the right edge of the bubble.\n\n**Verification:** chat 107/107 tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-mobile-round-31-voice-player-fit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ee43f0d5-fa7b-4a07-95ec-20075f3e7dd4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-polish-round-10-message-context-menu","type":"added","scope":"chat","summary":"Right-click any message for a full action menu; channel + message detail surfaces aligned to V2 tokens.","body":"Round 10 ships a real gap, not just polish: **messages now have a\nright-click context menu** matching Slack / Discord / iMessage\nconvention. Plus the existing channel context menu and the\nmessage-detail modal align to the V2 token system finished in\nrounds 7-9.\n\nWhat changed:\n\n**NEW — message context menu (`MessageContextMenu`):**\n\nA new portal-rendered menu at `apps/web/src/components/chat/message-\ncontext-menu.tsx`. Mirrors every action the hover toolbar exposes\nbut rises at the cursor on right-click so users on trackpads /\ntouchscreens / keyboard nav reach the same actions without\nprecision-targeting the floating pill.\n\n- Quick-react strip at the top: same six emojis as the hover toolbar\n  (👍 ❤️ 🎉 🙌 😂 👀) + a Smiley button that opens the full picker.\n  Hidden on own / AI / deleted / card rows where reactions don't\n  apply.\n- Action items ordered by industry convention: Reply in thread →\n  Bookmark / Save → Mark unread → Pin / Unpin → Copy text → Copy\n  link to message → Message details → Edit → Delete.\n- Each item gates on the existing permission / state guards: pin\n  hidden when not allowed; edit / delete hidden when not the actor\n  or moderator; mark-unread only on others' channel messages;\n  copy-link only when the row has a `channelId`.\n- Bookmark item label flips (\"Bookmark\" → \"Remove bookmark\") and\n  icon weight flips (regular → fill) to reflect current state.\n- Dismissal: outside click, Escape, scroll-outside-menu — same\n  behavioral contract as the channel context menu.\n- Modifier-click (Ctrl / Cmd) suppresses the custom menu so power\n  users still get the native browser menu.\n- Active text selection inside the row suppresses too, so the user\n  can run \"Copy\" / \"Search Google for…\" on a highlighted substring\n  without hijacking.\n- Same glass chassis as the V2 popovers: 14 px blur + 150%\n  saturation, 6%-foreground border, layered shadow (48 px ambient\n  + 10 px contact + 1 px ring).\n\nWired into `MessageItem` via `onContextMenu` on the row element.\nThree handler helpers (`toggleBookmark`, `markUnread`,\n`copyMessageText`) extracted so both the hover toolbar and the new\nmenu invoke identical logic — no behavior drift.\n\n**Channel context menu polish (existing surface):**\n\n- Width 240 → 256 px, vertical padding 1 → 1.5; portal gains\n  `data-helios-chat-popover` so V2 caption tokens apply.\n- Chassis swaps to the V2 glass language: 92% bg, 14 px blur,\n  150% saturation, color-mix border, layered shadow (48 px\n  ambient + 10 px contact + 1 px ring).\n- Entrance animation: `helios-chat-popover-in` (160 ms cubic-\n  bezier rise + 96 → 100% scale) — matches every other chat\n  popover.\n- Title header migrated to `--text-chat-caption` (11 px) uppercase\n  0.08em tracking, with a 5%-fg hairline divider so it reads as a\n  designed label, not a clickable item.\n- Action rows on `--text-chat-row` (14 px) with `gap-2.5 px-3`\n  (was `gap-2 px-2.5 text-[12.5px]`). Icons 13 → 14 px.\n- Submenu items on `--text-chat-meta` (12 px) — preserves the\n  visual hierarchy of \"primary action\" vs \"sub-option\".\n- Leave-channel separator goes 6%-fg color-mix at `my-1` (was\n  border-subtle at `my-0.5`) — a softer divider that matches the\n  rest of the V2 menu language.\n\n**Message detail modal polish:**\n\n- Author name on `--text-chat-label` tracking-tight; AI badge\n  promoted to a rounded-full uppercase pill (matches the V2 hover-\n  toolbar AI badge language).\n- Timestamp on `--text-chat-meta`.\n- Reaction-row count + reactor list on `--text-chat-meta`; inner\n  vertical separator switches to a 10%-fg color-mix.\n- Section labels (`Attachments`, `Reactions`) go to `--text-\n  chat-caption` (11 px) uppercase 0.10em tracking with weight 600\n  — match the round-7 marketing-card site-name pattern.\n- Footer buttons (Copy link / Open thread / Open in channel)\n  migrated to `rounded-full px-3 py-1.5` with V2 meta sizing —\n  align with the \"designed pill\" language used everywhere else in\n  the V2 work.\n- Status chips at the top (Pinned / Resolved / N replies) become\n  10 px 0.06em uppercase semibold pills — match the row-metadata\n  pill language landed in round 7.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-10 file errors.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-polish-round-10-message-context-menu.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e5faa2c6-cf1d-439d-b9ac-449d5d5bf585","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-4-full-ai-mention-alias","type":"changed","scope":"chat","summary":"V3 Bet 4-full — `@ai` becomes a brand-neutral alias for `@helios`; same inline-channel-participant pattern, shorter token.","body":"V3 Bet 4-full from [`docs/chat/UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §2.1 — AI as a first-class stream participant in channels.\n\nThe audit found this is **already shipped** via `modules/chat/src/jobs/on-helios-mention.ts`. The handler:\n\n- Subscribes to `chat.message.posted`\n- Detects `@helios` in the body\n- Pulls channel context (last 12 messages)\n- Calls the AI with the tool registry + `chat:*` / `crm:*` / `hrm:*` / `projects:*` read+create permissions\n- Posts the reply back into the channel as an AI-authored message in a thread under the originating message\n\nThe only gap was the **brand-neutral token**. Bet 4-lite (round 4-lite, `50c1591e`) added an `@ai` composer hint, but typing `@ai` did nothing — only `@helios` triggered the handler.\n\n**This commit:** extends `MENTION_RE` from `/@helios\\b/i` → `/@(?:helios|ai)\\b/i`. Now both `@helios` and `@ai` trigger the same handler with identical behavior.\n\nComposes cleanly with:\n- Bet 4-lite's `@ai` discovery hint (the hint that surfaces above the composer when the user types `@ai`) — the round-4-lite hint now correctly maps to what happens when they hit Send\n- The existing Ask-AI-thread pattern (`@helios` continues to work as before for users who learned that token)\n\n**V3 progress: 7 / 8 bets shipped + Bet 3 Phase 1 foundation.** Remaining: Bet 3 Phases 2-5 (streaming UI) and Bet 7 (voice notes first-class).\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-4-full-ai-mention-alias.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ceac2647-8191-4aff-b36c-c43f8c29bc47","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-4-lite-ai-mention-discovery","type":"added","scope":"chat","summary":"V3 Bet 4 (lite) — typing `@ai` in the composer surfaces a tinted hint that opens the Ask AI side pane.","body":"V3 Bet 4 from [`docs/chat/UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §2.1 — *AI as a first-class stream participant* — has two halves:\n\n- **Full version** (deferred): typing `@ai` posts an inline message in the channel; the AI streams a token-by-token reply in the same channel as a thread. Needs backend AI-in-thread plumbing + a streaming wire.\n- **Lite version** (this commit): typing `@ai` surfaces a tinted discoverability hint above the composer with an \"Open Ask AI →\" CTA that opens the existing AI side pane. Users learn the `@ai` shortcut without us having to ship the streaming reply path yet.\n\nThe lite version is the discoverability surface — it teaches users that `@ai` is the way to talk to the AI. When the streaming-reply backend lands, we swap the hint for the inline thread and the muscle memory carries over.\n\n**What lands:**\n\n- Composer's `onDraftChange` callback in `channel-view.tsx` runs a `/(^|\\s)@ai(\\s|$)/i` regex on every draft change. Word-boundaries prevent false positives on `@aiyana` / `@email`.\n- When `@ai` is detected AND the AI pane isn't already open, a rounded-full glass pill renders **just above the composer** (between the AI-thinking indicator and the smart-reply chips). The pill reads \"Mentioning @ai? Open Ask AI to chat with it →\" with a breathing Sparkle medallion.\n- Click → dispatches `helios:chat:ai-from-mention` window event.\n- `channel-view.tsx` listens for that event (and the round-1 `helios:chat:ai-open` event from the Cmd+K palette) and toggles `aiThreadOpen` to true. Both palette-open and mention-open share the same code path.\n- The hint hides when the AI pane is already open OR when the user removes `@ai` from the draft.\n\n**Styling:**\n\n- Same AI-tint language as round-9's quick-reply \"Suggested\" header pill: 10% AI-tint bg + 28% AI-tint border + AI-600 text, rounded-full.\n- Hover lifts -0.5 px with a 18 px AI-tint glow shadow (signature interaction style from V2).\n- Sparkle medallion has the standard `helios-chat-ai-sparkle` ambient breathing.\n\n**Why a hint and not the full thing yet:**\n\nBuilding inline-AI-in-the-channel-stream needs:\n1. A new message authorType handling for streaming text\n2. TanStack AI streaming wired through `chat.message.post`\n3. AI-as-participant role wired in `chat_channel_members`\n4. Realtime envelope for the streaming tokens\n5. UI rendering for tokens-as-they-arrive\n\nThat's 3-4 days of focused work + backend changes. The lite version is a few hours; ships the user-discoverability half of the value now; the full inline-stream lands when the backend infrastructure does.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck clean; @helios/web typecheck zero touched-file errors.\n\n**V3 progress: 4 / 8 bets shipped** (1 Cmd+K, 2 dense inbox, 4-lite `@ai` mention, 6 fluid typography). Remaining: bets 3 (streaming AI), 4-full (inline `@ai` participant), 5 (huddle recap), 7 (voice notes first-class), 8 (split-view) — all need backend changes and remain flagged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-4-lite-ai-mention-discovery.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"23c7e3c5-500f-4cf1-bea7-21e656f6e961","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-5-huddle-recap-card","type":"added","scope":"chat","summary":"V3 Bet 5 Path A — every huddle that runs > 60 s with chat activity now ends with an AI-summarised recap card posted to the channel.","body":"V3 Bet 5 (Path A) from [`docs/chat/V3_BET_5_HUDDLE_RECAP.md`](../../docs/chat/V3_BET_5_HUDDLE_RECAP.md). The fastest of the four backend-dependent bets — the spec audit found `chat.huddle.summarize` already exists, so the only remaining work was the **event wiring** + **transcript gathering**.\n\n**What user sees:**\n\nA huddle ends → a few seconds later, a recap card lands in the channel where the call ran. The card carries:\n- Topic line (\"Sprint planning · 32 min\")\n- A 2-4 sentence narrative\n- **Decisions** the team made\n- **Action items** with assignees when inferrable\n- **Next steps** + open questions\n\nPattern parity: Microsoft Teams Intelligent Recap, Slack Huddle Notes, Google Meet AI summary.\n\n**How it's wired (this is Path A — the cheap, ships-fast variant):**\n\nThe huddle channel where the call happened already has chat messages typed alongside speaking — async confirmation, link sharing, \"+1\" reactions to verbal points. On `chat.huddle.ended`, the new `on-huddle-ended` job gathers those messages from the call window as a **pseudo-transcript** and feeds them to the existing `chat.huddle.summarize` action with `postToChannel: true`.\n\nBetter paths (server-side WebSpeech / Whisper transcription) are spec'd in `V3_BET_5_HUDDLE_RECAP.md` §Path B/C as later upgrades. Path A ships **today** and produces a useful recap on any call where the team typed at all during it.\n\n**Gates (fails-soft at every layer):**\n\n- Call < 60 s → skip (too short for a meaningful meeting)\n- < 3 chat messages during the window → skip (not enough signal; would generate noise)\n- No active huddle row → skip (edge case)\n- AI unavailable → the summarize action returns `ok=false reason=ai_unavailable`; we log + skip\n\nWhen all gates pass, the AI gets:\n- Channel id + duration\n- The list of distinct authors as `participants` (speakingSec=0 since pseudo)\n- Up to 300 messages from the call window as transcript segments, capped at 2000 chars each\n\nThe resulting summary posts as a regular chat message via the summarize action's existing `postToChannel: true` path — renders with the existing message-list polish + the round-7+ AI sparkle on the author block.\n\n**Files:**\n\n- New: `modules/chat/src/jobs/on-huddle-ended.ts` (~190 lines)\n- Modified: `modules/chat/src/jobs/index.ts` registers the new handler\n\n**Verification:** chat 107/107 tests pass; @helios/chat typecheck clean.\n\nV3 progress: **5 / 8 bets shipped** (1 Cmd+K, 2 dense inbox, 4-lite `@ai`, 5 huddle recap, 6 fluid typography). Remaining: bet 3 (streaming AI), bet 4-full (`@ai` inline participant — waits on 3), bet 7 (voice notes first-class), bet 8 (split-view).\n\nThe huddle recap card composes with everything before it:\n- Search (round 19) indexes the card body — \"what did we decide about pricing\" surfaces relevant recaps\n- Convert-to-ticket from the More menu (round 18) works on the recap card, so a decision becomes a tracked ticket\n- The bookmark + pin flows work normally — recaps can be saved or pinned","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-5-huddle-recap-card.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4beaf330-9c76-41b1-b4fa-8d6b1ec64409","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-6-fluid-typography","type":"changed","scope":"chat","summary":"V3 Bet 6 — chat type tokens go fluid via `clamp()` so the scale flows from 13\" laptop to 32\" ultrawide without per-breakpoint media queries.","body":"V3 Bet 6 from [`docs/chat/UI_REVAMP_V3_RESEARCH.md`](../../docs/chat/UI_REVAMP_V3_RESEARCH.md) §2.4. Smallest of the eight V3 bets; first to ship because it's pure CSS.\n\n**What changed:**\n\nEvery chat type token (`--text-chat-display` → `--text-chat-mono`) migrates from a fixed `px` value to a `clamp(min, vw-fluid, max)` declaration.\n\n```css\n--text-chat-body: clamp(14.5px, 0.9rem + 0.15vw, 16.5px);\n```\n\n- **min** keeps the 13\" laptop reading honest (≈ V2's value)\n- **vw component** is small (0.05–0.4vw depending on token size) — type doesn't double on a wider viewport; it settles into the right rhythm\n- **max** caps the growth at the largest reasonable arm's-length size\n\nThe Compact / Default / Cozy density toggle still applies as a fixed-value override on top — it sets explicit `px` values per density. So power users on dense workstations still get V1-style density when they choose it; everyone else gets the fluid V2 scale that smoothly scales.\n\n**Why this matters:**\n\n- A single declaration replaces what would otherwise be `@media (min-width: 1280px)` blocks duplicated across every chat surface\n- The type scale follows the user's actual viewport — a 32\" ultrawide rendering 14 px body looks anemic; clamp lets it grow to 16.5 px without designer intervention\n- Works with the browser's font-size accessibility settings — rem-based math respects the user's root font-size\n\n**No call-site changes** — every `var(--text-chat-*)` reference re-skins automatically. The Density toggle keeps its fixed-value behavior intact.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-6-fluid-typography.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6b3066ac-d165-494b-a64d-f2b4fe96a896","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-v3-bet-8-split-view","type":"added","scope":"chat","summary":"V3 Bet 8 — split-view lets power users pin two channels side-by-side with a draggable resize handle, ⌘\\ shortcut, and Cmd+K palette entry.","body":"V3 Bet 8 from [`docs/chat/V3_BET_8_SPLIT_VIEW.md`](../../docs/chat/V3_BET_8_SPLIT_VIEW.md). Slack March-2026 + VS Code split-editor pattern brought to Helios chat.\n\n**What user sees:**\n\n- **`⌘\\` / `Ctrl+\\`** anywhere on a channel page → split-view opens with the last secondary channel the user split into. If no remembered secondary, the Cmd+K palette opens in \"Pick a channel to split into\" mode.\n- **Two channel columns side-by-side** with their own scroll, composer draft, thread pane, AI pane. State is per-pane and never crosses.\n- **Draggable vertical handle** between the columns — drag to resize the split ratio (clamped 20–80%). Double-click to reset to 50/50.\n- **Per-user persisted ratio** — `localStorage.helios.chat.split.primary-pct` survives reload.\n- **URL-shareable layout** — `?split=<channelId>` on `/chat/$channelId`. Copy-paste the URL and your teammate lands in the same split.\n- **Close buttons** — hover over a column → tiny `×` appears in the top-right of each pane. Closing the secondary drops to the primary; closing the primary swaps the secondary into the primary slot.\n- **Cmd+K palette entry** — \"Open another channel in split view…\" surfaces the split flow non-discoverably; clicking it flips the palette into a \"Pick a channel to split into\" mode.\n- **Mobile fallback** — below the `md` breakpoint the secondary pane hides entirely; the `?split=` param is preserved across the breakpoint so a phone user who resizes to desktop sees the split immediately.\n\n**How it's wired:**\n\n- New `apps/web/src/components/chat/split-channel-view.tsx` (~220 lines) — owns the two columns + the draggable resize handle. Each column is a thin wrapper around the existing `<ChannelView>` (which was already pane-clean — V3 Bet 8 spec audit confirmed this; no internal changes to ChannelView were needed).\n- New `?split=<channelId>` search-param in `apps/web/src/routes/chat/$channelId.tsx`. When present (and not equal to the primary), render `<SplitChannelView>`; otherwise render the bare `<ChannelView>` as before.\n- `⌘\\` keyboard listener registered in the route component — toggles the split. Remembers the last secondary in `localStorage.helios.chat.split.last-secondary`.\n- Cmd+K palette gains a `splitMode` state. In split mode, channel rows navigate to `?split=` instead of bare; placeholder text + hints reflect the mode; the current channel is filtered out (no self-split).\n- Custom event `helios:chat:palette-open-for-split` dispatched from the route → palette listens → opens in split mode. Decouples the route from the palette (matches the round-1 dispatched-event pattern for AI / search / new-channel / new-DM).\n\n**Layout details:**\n\n- Resize handle: 6 px gutter with a 2 px-wide, 12 px-tall visible indicator that brightens on hover. `cursor: col-resize` while dragging.\n- Min panel width: 20%. Max: 80%. The handle clamps to these bounds.\n- Default split: 50/50.\n- Mouse capture during drag uses `mousemove` / `mouseup` on `window` for proper drag-outside-the-handle behavior.\n- Focused-pane indicator: 2 px module-chat top-edge accent on the column that received the most recent click. Drives which pane the (future) Cmd+W shortcut would close.\n\n**What's NOT in this commit (deferred to a follow-up if needed):**\n\n- Drag-from-sidebar-to-pane drop targets (the spec's Phase 4). The pieces exist — sidebar rows are draggable; columns can be wrapped with `onDrop` handlers — but it's a separate polish round.\n- Cmd+W / Cmd+1 / Cmd+2 explicit shortcuts. The X close button covers the close case; tab-and-click covers focus. Add them if user testing shows they're missed.\n- Three-pane layout. The spec is explicit: two panes max. Anyone needing three has bigger problems than a chat client can solve.\n\n**Verification:** chat 107/107 tests pass; @helios/chat typecheck clean; @helios/web typecheck zero touched-file errors.\n\n**V3 progress: 6 / 8 bets shipped** (1 Cmd+K, 2 dense inbox, 4-lite `@ai`, 5 huddle recap, 6 fluid typography, 8 split-view). Remaining: bet 3 (streaming AI), bet 4-full (`@ai` inline participant — waits on 3), bet 7 (voice notes first-class).\n\nComposes with everything before it:\n- Cmd+K palette (Bet 1) is the discoverability surface for split\n- The drag-handle + source-row-dim from round 17 work as-is in split-view\n- The huddle recap card (Bet 5) renders fine in a split column\n- Mobile responsive — split is hidden below md so phone UX stays single-pane","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-chat-v3-bet-8-split-view.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"0b5dc94e-b5f7-44b2-80d8-dc84f8cc830f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-a11y","type":"changed","scope":"web","summary":"Client portal accessibility pass — skip-to-content link and current-page markers in the nav.","body":"Accessibility polish for the client portal (WL-3). Added a \"Skip to content\" link\n(visible on keyboard focus) so keyboard and screen-reader users can bypass the\nheader, and the active portal nav tab now carries `aria-current=\"page\"`. The portal\nwas already responsive and rendered entirely from theme tokens, so it follows the\noperator's light/dark theme automatically.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-a11y.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a8cef967-49fe-4fa1-8f53-820e071d788a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-account-surface","type":"added","scope":"clients","summary":"Client-portal users now have a branded /account home showing their balance and invoices.","body":"The first slice of the authenticated **client portal**. A client-portal user\n(a contact provisioned via `crm.contact.invite_portal_user`) now signs in and\nlands on a dedicated, white-labeled `/account` surface — separate from the\ninternal app and from the public `/portal/$companyId` payment link.\n\n- New route group `/account` (`account.tsx` layout + `account.index.tsx` home)\n  rendered in a minimal, brand-aware `PortalShell` (operator logo / app name).\n- Client users (`users.type === 'client'`) are redirected from the internal\n  dashboard to `/account`; non-client identities are bounced out of `/account`.\n- New action `clients.portal.overview` returns the signed-in client's own\n  company, outstanding balance, open-invoice count, and recent invoices —\n  scoped **server-side** to the caller's linked client company\n  (`resolveClientScope`), so a client can never read another client's data.\n- The portal home is action-oriented (\"what needs your attention\"): outstanding\n  balance first, then recent invoices.\n\nThis is foundation for the broader portal (invoices/pay, quotes, projects,\nsupport, documents). Provisioning and access control shipped previously; this\nadds the surface a client actually sees.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-account-surface.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b1f424d6-331f-4e42-bc27-c45d565cc60e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-activity-feed","type":"added","scope":"clients","summary":"The client portal home now shows a \"Recent activity\" feed — invoices, quotes, and shared documents on the account.","body":"Added a curated activity feed to the client portal (`clients.portal.activity`),\nsurfaced as \"Recent activity\" on the portal home. It streams the\nclient-appropriate events on their account — invoices issued/paid/voided,\nquotations sent/accepted/declined, and documents shared by the agency — newest\nfirst, each linking to the relevant portal page. The feed is scoped server-side\nto the signed-in client's own company and never exposes internal CRM notes or\nsales-pipeline data, and each source is gated by the same per-contact surface\nvisibility as the rest of the portal (hide Invoices and the invoice events\ndisappear too).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-activity-feed.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"c1a8c800-b428-4f36-87e7-754be5391b56","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"support-sales-collapsible-nav","type":"changed","scope":"web","summary":"The Support and Sales sidebars now use the same collapsible-group tree as Settings.","body":"The Support (16 entries) and Sales (13 entries) module sidebars were already\norganized into groups but rendered as one long flat list. They now collapse\nlike the Settings and SaaS consoles — groups you aren't using fold away, and\nthe group holding your current page opens automatically. No links moved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-support-sales-collapsible-nav.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5f31930f-3553-41ce-9230-e5c59d9ba335","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-area-summaries","type":"added","scope":"clients","summary":"Each client-portal section (Projects, Invoices, Support) now opens with an AI summary of that area.","body":"Added per-area AI summaries to the client portal. The Projects, Invoices, and\nSupport pages now each lead with a short, client-scoped summary card —\n\"2 active projects\", \"1 invoice awaiting payment\", \"3 open requests\" — with a\none-line description and a few highlights of the key items. Like the home\nconcierge, these are deterministic and action-only (`clients.portal.area_summary`\ncomposes the existing scoped portal actions rather than introducing a separate AI\ndata path), scoped to the signed-in client, and they respect each surface's\nper-contact visibility.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-area-summaries.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"baf4e40a-aa23-478b-a612-24ff65c7a999","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-data-scope","type":"added","scope":"crm","summary":"Operators can restrict a portal contact to only the support tickets they raised, instead of all their company's.","body":"Added per-contact client-portal data scope (PERM-3). From a contact's Portal\naccess panel, an operator with `crm:contact:manage_portal_access` can set a\ncontact's data access to **All company records** (default) or **Only records they\nraised** via `crm.contact.set_portal_scope`. An \"own\"-scoped contact now sees and\ncan open only the support tickets they created themselves — enforced server-side\nin `support.portal.tickets` and the ticket thread, not just hidden in the UI.\nCompany-level surfaces (invoices, quotes, statement) are unaffected since they\nbelong to the whole client. Defaults to company scope, so existing contacts keep\nfull visibility.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-data-scope.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"1556495d-9efc-4f31-884a-cf2158b5b16c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-document-upload","type":"added","scope":"clients","summary":"Clients can upload files from the portal Documents page; operators see them badged \"Client upload\" on the client record.","body":"Added self-serve document upload to the client portal. From Documents, a signed-in\nclient can now send a file back to the agency — the same two-step presigned upload\nthe operator side uses (mint a PUT URL → upload direct to storage → confirm), so the\nstorage key is never exposed and a client can only ever attach to their own company\n(server-resolved scope, not a client-supplied id). Uploaded files appear immediately\nin the client's own list (\"You uploaded\") and on the operator's client-detail\nDocuments tab with a \"Client upload\" badge. Upload is gated by the same per-contact\nDocuments visibility as the rest of the surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-document-upload.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4b1fbbc0-3e15-46a2-a9e4-00290663780c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-documents","type":"added","scope":"clients","summary":"Client-portal users can now view and download the agreement documents shared with them.","body":"Adds the **Documents** surface to the client portal.\n\n- New `/account/documents` page lists the agreement files (NDAs/MSAs/SOWs/signed\n  quotations) attached to the signed-in client's own company, with a Download\n  action that opens a short-lived presigned link.\n- New action `clients.portal.documents` — client-scoped list\n  (`resolveClientScope`), confirmed uploads only, raw storage key never exposed.\n- New action `clients.portal.document_download_url` — re-signs a presigned GET\n  after verifying the document belongs to the caller's client company and sits\n  under this org's storage prefix.\n- Portal nav gains a Documents tab.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-documents.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b062f008-7b7d-4c36-b92c-2faa386971ba","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-home-dashboard","type":"changed","scope":"clients","summary":"The client-portal home is now an action-oriented dashboard linking across all surfaces.","body":"The `/account` home now leads with \"what needs your attention\" stat cards —\n**Outstanding** balance, **Quotes to review**, **Active projects**, and **Open\ntickets** — each linking to its surface, above the recent-invoices list. Counts\nare computed from the existing client-scoped portal actions.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-home-dashboard.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"afe46f61-8b91-4122-a0c6-6fab238d2daf","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-invoices-pay","type":"added","scope":"clients","summary":"Client-portal users can now view all their invoices and pay outstanding balances online.","body":"The first full surface of the client portal: **Invoices**.\n\n- New `/account/invoices` page lists every invoice for the signed-in client’s\n  own company (drafts hidden), each with status, dates, and open balance.\n- A **Pay** button on payable invoices starts a hosted checkout and hands off to\n  the existing `/pay/c/<session>` flow.\n- New action `clients.portal.invoices` — client-scoped invoice list\n  (`resolveClientScope`; never another client’s data).\n- New action `sales.invoice.client_pay` — the authed-client counterpart of\n  `sales.invoice.public.pay`: verifies the invoice belongs to the caller’s\n  linked client company (server-resolved), refuses paid/void/written-off and\n  zero-balance invoices, and mints a checkout session via the existing\n  `initiatePayment` path. No new payment engine.\n- The portal shell gains Home / Invoices navigation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-invoices-pay.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"05d2e98f-2595-4795-9714-d3672920c1e7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-notification-prefs","type":"added","scope":"clients","summary":"Clients can choose which emails they receive (invoices, quotes, subscriptions) from a new portal Settings page.","body":"Added per-contact notification preferences to the client portal (PERM-2). A new\nSettings page lets each client turn off the email categories they don't want —\nInvoices, Quotes, or Subscriptions — managed entirely by themselves\n(`clients.portal.notification_prefs.get` / `.set`, scoped to their own contact).\nThe preference is enforced, not cosmetic: the customer-facing sales email dispatch\nnow checks `crm_contacts.portal_notification_prefs` before sending an invoice,\nquote, or subscription email and skips the send when that category is off. It's an\nopt-out model — every category defaults to on, and a recipient with no contact\nrecord or no preference set still receives everything.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-notification-prefs.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"92a72ae3-9521-4638-b0c2-e0cc415345e5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-dead-section-detector","type":"added","scope":"website","summary":"Phase 17.G — `website.audit.scan_dead_sections` action + a paired panel on `/saas/website/audit` that catches global_ref / custom sections pointing at archived or deleted targets.","body":"Phase 17.D's audit caught broken HREFs. The OTHER silent-failure\nmode in the CMS — `global_ref` and `custom` sections pointing at\na `website_globals` or `website_section_definitions` row that was\nlater archived / deleted / never published — still rendered as a\nblank placeholder + a `console.warn` on the public site.\n\nOperators only learned about it from a \"why is this section\ngone?\" support ticket.\n\nThis commit adds a sibling read-only action and surfaces it as a\nsecond panel on the audit page so the two integrity scans run\nside-by-side:\n\n**Action: `website.audit.scan_dead_sections`** — walks every\npublished page in the org, finds every `global_ref` / `custom`\nsection, and flags those whose target row is missing, archived,\nor still in draft. Per-finding: source page (id + kind + slug +\nlanguage + title), section index, section type, the ref slug,\nand one of three reasons:\n\n- `target_not_found` — no `website_globals` / `website_section_definitions` row\n- `target_archived` — target soft-deleted or status=archived\n- `target_draft` — target still pre-publish\n\nCross-language fallback for `global_ref` (try source locale,\nfall back to `en`) mirrors the public renderer. 4 new tests.\n\n**Audit page** — a second \"Dead section references\" panel below\nthe existing broken-links panel. Re-scan button now fires both\naudits in parallel and reports unified counts (e.g. \"Scanned 42\nlinks + 18 sections — all clean.\"). Both panels URL-share the\n`?includeDrafts` filter.\n\n292 / 19 website tests green (+4 new). Web typecheck clean.\n\nDefers (17.G remaining): multi-reviewer approval workflow,\npage-level ACLs, tag-usage reconciliation cron. Those are\nschema-touching changes that don't fit a single commit alongside\nthis content-integrity surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-dead-section-detector.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"cf84d051-0703-4988-8ef7-ebafe661c71e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-project-detail","type":"added","scope":"projects","summary":"Clients can open a project from the portal to see its description, milestone roadmap, and overall progress.","body":"Added a client-facing project detail view to the portal (SURF-4). From Projects,\na client can now open one of their own projects to see its description, status and\ndate range, the milestone roadmap (each milestone's name, status, and due date), and\nthe overall task progress bar. The new `projects.portal.project_detail` action\nverifies the project belongs to the signed-in client's own company before returning\nanything — a client can never open another client's project — and deliberately\nexposes only the client-facing roadmap: internal task titles, assignees, notes, and\ncomments are not surfaced.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-project-detail.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"d54d9ce8-2ffe-4ba2-9788-ed5af6a91c8b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-project-files","type":"added","scope":"projects","summary":"Clients and the delivery team can now share files on a project — clients upload/download from the portal, operators from project settings.","body":"Added a client-facing file-share to projects (SURF-4). On the portal project page a\nclient can upload files and download anything shared on the project; operators do\nthe same from Project → Settings → Client files, and both sides see every file with\na \"Client upload\" / \"Shared by you\" badge. It's a dedicated shared channel, separate\nfrom the internal Data Panel artifacts — internal attachments never cross over.\nBacked by eight actions over a new `projects_client_files` table using the standard\ntwo-step presigned upload (the storage key is never exposed); the portal read/upload/\ndownload are scoped to the client's own company (IDOR-safe) and the client upload is\nrate-limited like the other external portal writes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-project-files.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"17d9a42f-87f5-4c25-bb06-7d96fe1eebdd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-project-messages","type":"added","scope":"projects","summary":"Clients and the delivery team can now message each other about a project from the portal — a channel separate from internal project chat.","body":"Added a client-facing message thread to projects (SURF-4). On the portal project\ndetail page a client can read and post messages about their project, and operators\nsee and reply to the same thread from Project → Settings → Client messages. This is\na dedicated client channel, deliberately separate from the internal project chat —\nonly what's posted here reaches the client, so internal discussion never leaks.\nBacked by four actions (`projects.portal.project_messages` / `_message_post` and\nthe operator-side `projects.project.client_messages` / `_message_post`); the portal\nread/post are scoped to the client's own company (a client can never reach another\nclient's project) and the client post is rate-limited like the other external\nportal writes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-project-messages.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"bf34aa5c-a7ba-425a-a694-91c58e287c39","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-projects","type":"added","scope":"clients","summary":"Client-portal users can now see the status and progress of the projects being delivered for them.","body":"Adds the **Projects** surface to the client portal.\n\n- New `/account/projects` page shows the signed-in client's own projects with\n  status and a task-progress bar (done / total).\n- New action `projects.portal.projects` — scoped to the client's company via the\n  `crm_contacts.user_id` link (a client never sees another client's projects);\n  internal task/comment detail is not exposed. Read-only.\n- Portal nav gains a Projects tab.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-projects.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4cbee21d-71b7-4bba-aede-7a0e72f3d1fa","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-quotations","type":"added","scope":"clients","summary":"Client-portal users can now review quotations and accept or decline them online.","body":"Adds the **Quotations** surface to the client portal.\n\n- New `/account/quotations` page lists the quotes sent to the signed-in client’s\n  own company (drafts hidden), with **Accept** / **Decline** actions on quotes\n  still in sent/viewed state.\n- New action `clients.portal.quotations` — client-scoped list\n  (`resolveClientScope`) with a `respondable` flag.\n- New action `sales.quotation.client_respond` — the authed-client counterpart of\n  the recipient-side accept/decline path: verifies the quote belongs to the\n  caller’s linked client company, only transitions sent/viewed quotes, and fires\n  the same `quotationAccepted` / `quotationDeclined` events the public path does\n  (so downstream automation is identical).\n- Portal nav gains a Quotes tab.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-quotations.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f64f2982-4edc-43b2-847c-330849361291","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-quote-esign","type":"added","scope":"sales","summary":"Clients now type their name to e-sign when accepting a quote from the portal; the signature is recorded on the quote.","body":"Accepting a quotation in the client portal is now a signed action (SURF-3). The\n\"Accept\" button opens a sign dialog where the client types their full name and\nconfirms — the typed e-signature is stored on the quotation\n(`accepted_signature_name`) alongside the acceptance timestamp as the\nsignature-of-record. The dialog states the amount and that this is a legally\nbinding electronic signature, and renders a live signature preview. Accepting\nwithout a name is rejected server-side. On the operator's quotation detail, an\naccepted quote now shows \"Electronically signed by <name>\". Quotes accepted via\nthe older no-signature path keep a null signature.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-quote-esign.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"54306153-9452-48c9-8fdd-6c97d947dbc2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-statement","type":"added","scope":"clients","summary":"Client-portal users get a consolidated account statement (balance, invoices, credit notes).","body":"Adds the **Statement** surface to the client portal.\n\n- New `/account/statement` page — a printable consolidated account statement:\n  total outstanding, the invoice ledger (per-invoice balances), and credit notes\n  with their remaining (unapplied) amount.\n- New action `clients.portal.statement` — client-scoped (`resolveClientScope`),\n  excludes drafts/voids, computes the open balance in the client's main currency.\n- The statement also lists the client's active/trialing/past-due subscriptions\n  with their per-cycle amount and next billing date (read-only; self-serve\n  pause/cancel is a follow-up).\n- Portal nav gains a Statement tab.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-statement.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3ba551f4-56e9-4b1a-877f-3250680c5c79","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-page-acls","type":"added","scope":"website","summary":"Phase 17.G.3 — per-page ACLs. `website_page_acls` table + `grant`/`revoke`/`list` actions + edit-gate wired into update/publish/archive.","body":"Anyone with org-wide `platform:website:page:update` could edit\nevery page in the org. For a marketing site with multiple teams\n(engineering writes /developers, legal writes /privacy, design\nwrites /brand), the org-wide permission was too coarse.\n\nThis commit adds an opt-in narrowing layer.\n\n**Schema** — `website_page_acls` (migration `0216_0217`). Per-\npage rows with `(user_id, role)`. Two roles via the new\n`website_page_acl_role` enum:\n\n- `editor` — can `update` + `request_review`\n- `approver` — editor + `publish` + `reject_review` + `archive`\n\nA single user holds at most one ACL entry per page (unique\nindex on `(page_id, user_id)`); re-granting upgrades /\ndowngrades the role rather than duplicating. Cascade on\npage delete + user delete so we never carry stale ACL rows.\n\n**Permission** — new `platform:website:page:acl:manage` for\nthe CRUD surface. Distinct from `:update` because managing\nWHO can edit a page is a separate authority from editing it.\nRoot-only by default like every other `platform:website:*`.\n\n**Actions** — `modules/website/src/actions/page-acl.ts`:\n\n- `website.page.acl.grant({ pageId, userId, role })` — upserts;\n  returns the row id + role. Verifies the page belongs to the\n  caller's org (cross-org guard).\n- `website.page.acl.revoke({ pageId, userId })` — idempotent\n  delete; returns `{ removed: boolean }`.\n- `website.page.acl.list({ pageId })` — returns the ACL roster\n  joined to `users.name` / `users.email` so the admin UI can\n  render names without a second round-trip.\n\n**Enforcement** — `modules/website/src/lib/page-acl.ts` exposes\n`checkPageAcl({ db, pageId, actor, requirement })` returning a\n`Result<undefined, ActionError>`. Contract:\n\n1. Root operators (`platform:root`) bypass entirely.\n2. Page has zero ACL rows → unrestricted; allow.\n3. Page has rows → actor must have a sufficient-role entry.\n   - `'editor'` requirement satisfied by editor OR approver.\n   - `'approver'` requirement satisfied by approver only.\n\nWired into the three highest-stakes verbs:\n- `updatePage` — requires `editor`\n- `publishPage` — requires `approver`\n- `archivePage` — requires `approver`\n\n`requestReviewPage` / `rejectReviewPage` / batch ops land in a\nfollow-up so this commit stays focused on the data layer +\nthe most-trafficked write paths.\n\n**Backwards-compatible** — every existing page has zero ACL\nrows; the gate falls through to the legacy org-wide\npermissions for all of them. Operators opt in by adding the\nfirst ACL entry on a page.\n\n**Tests** — 13 new tests:\n- 6 pure `evaluatePageAcl` tests (root bypass / unrestricted /\n  actor not in list / editor sufficient / editor not approver /\n  approver covers both).\n- 7 action tests (grant happy / grant cross-org / grant policy\n  denied / grant validation / revoke removed / revoke\n  idempotent / list with user names).\n\nThe pre-existing publish/update/archive tests needed a tweak\nto the scheduled-publish-cron stub (interleave page lookups\nwith empty ACL lookups), but no test was deleted — 325 / 22\nwebsite tests green (+13 new). Website typecheck clean.\n\nAdmin UI for the ACL panel + the remaining edit-flow wire-ins\n(request_review / reject_review / batch ops) are deferred to\n17.G.3.b follow-ups; operators can wire ACLs via the action\ncatalog or programmatically today.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-page-acls.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"19dd3e3d-31e5-453e-8221-a0b60eb5d9b4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-subscription-self-serve","type":"added","scope":"clients","summary":"Clients can now cancel their own recurring subscription from the portal Statement page.","body":"Added subscription self-serve to the client portal (SURF-7). Active recurring\nsubscriptions on the Statement now show a Cancel action; confirming it stops\nbilling via `clients.portal.subscription_cancel`. The action verifies the\nsubscription belongs to the signed-in client's own company and that the Statement\nsurface is visible, then routes through the existing `sales.subscription.cancel`\n(structured reason, audit, churn analytics) via a bounded system context — the\nclient never holds operator subscription permissions directly. Idempotent, and\nmarked dangerous so the AI runtime confirms before firing.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-subscription-self-serve.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"53759432-d25c-4bdb-ad1c-5399b183d649","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-support","type":"added","scope":"clients","summary":"Client-portal users can now see their company's support tickets and open new ones.","body":"Adds the **Support** surface to the client portal.\n\n- New `/account/support` page lists the support tickets for the signed-in\n  client's own company (with status), and a \"New ticket\" form opens one.\n- New action `support.portal.tickets` — client-scoped ticket list, filtered to\n  the caller's linked client company (IDOR-safe; a client never sees another\n  client's tickets).\n- New action `support.portal.ticket_create` — opens a ticket with the requester\n  **pinned server-side** to the signed-in client (user + their company + contact\n  email); client-supplied requester fields are ignored, so a client can't open a\n  ticket on another company's behalf. Delegates to `support.ticket.create` so the\n  reference counter, status defaults, first-message sequence, SLA, and events\n  stay in one place.\n- Portal nav gains a Support tab.\n- New action `support.portal.ticket_thread` — the ticket + its public message\n  thread (internal notes excluded; plain text only — no raw HTML to the\n  portal), company-scoped.\n- New action `support.portal.ticket_reply` — posts a public reply on a ticket\n  the client opened (ownership-verified), delegating to `support.ticket.reply`\n  so seq/SLA/auto-reopen/events stay in one place.\n- New `/account/support/$id` thread page with a reply box.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-support.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"de5e946c-7e1d-43f4-a9dc-9219a9d2ed9c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-team-management","type":"added","scope":"clients","summary":"A client's primary contact can now invite and remove portal logins for their colleagues from a new portal Team page.","body":"Added self-service team management to the client portal (CU-6). The client's\n**primary contact** sees a new \"Team\" tab listing everyone at their organisation\nwith their portal-login status (has access / invite pending / no access), and can\ngrant or revoke access for any colleague without involving the agency. Access\ncontrol is enforced server-side: only the primary contact may invite/revoke, the\ntarget must belong to the same company, and the primary can't revoke their own\naccess. Under the hood the portal actions delegate to the existing\n`crm.contact.invite_portal_user` / `revoke_portal_access` through a bounded system\ncontext, so the portal user never holds operator permissions directly. Non-primary\ncontacts don't see the Team tab.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-team-management.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"13d34dc4-adf5-4546-b1d1-c5a1e4d762ac","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-welcome","type":"added","scope":"web","summary":"New clients see a one-time welcome card orienting them to the portal on first sign-in.","body":"Added a first-run welcome to the client portal (ONB-1). The first time a client\nopens their portal home they see a short, dismissible welcome card — branded with\nthe operator's app name — that orients them to what the portal can do (pay\ninvoices, respond to quotes, follow projects, raise support, find documents). It's\ndismissed per user and never shown again. A deeper onboarding (intake forms via\n@helios/forms) is a follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-client-portal-welcome.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"338c8aa8-a89f-4e3e-87f3-c38ff4384fba","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"clients-detail-invite-primary","type":"added","scope":"web","summary":"CU-3 (partial) — one-click \"Invite primary contact to portal\" button in the client-detail header, backed by CU-1.","body":"CU-1 (`clients.client.invite_portal_user`) shipped the action;\nthis commit wires it into the most natural UI surface — the\nclient-detail page header — so operators can grant portal\naccess to a client's primary contact in one click without\nexpanding the contacts panel.\n\n**New header button** \"Invite primary\" — visible when:\n- the actor holds `crm:contact:manage_portal_access`\n- the client has `primaryContactId` set\n- that primary contact's `portalStatus === 'none'`\n\nThe button fires `clients.client.invite_portal_user` with just\nthe client id; the action picks the primary contact, runs the\nCRM provisioning path, and emits both events.\n\n**On success**:\n- Status `linked` (existing account by email): success toast\n  \"Client-portal access granted.\" Refetch refreshes the\n  contact's portal status pill.\n- Status `invited` (new invitation issued): show-once reveal\n  modal with the invite URL + 16-hex code + copy buttons.\n  Mirrors the existing per-contact pattern; operator copies +\n  closes; the contact's portal status flips to `invited` on\n  refetch so the button hides itself.\n\n**On error**: surfaces the action's `ActionCallError` message\nin a toast (the action returns `not_found` when no primary\ncontact is set or `conflict` when the primary already has\naccess; the button is hidden in those cases but the toast is\ndefensive).\n\nPure UI; no schema or action changes. Web typecheck clean.\n\nThe contacts-panel per-row invite buttons stay unchanged so\nnon-primary contacts still work the same way.\n\nRemaining CU-3 work — wiring the `UserLinkPicker` into the\nclient-create modal + the deal/lead Convert flow — needs a\nlarger restructure (the create modal doesn't capture a contact\ntoday) and is deferred to a follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-clients-detail-invite-primary.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"87432fc8-b9b9-4eed-893c-677c11f7d38b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-configurable-pipelines-foundation","type":"added","scope":"crm","summary":"Added configurable sales pipelines & stages (data model + read actions); each org gets a default pipeline.","body":"Laid the foundation for **configurable sales pipelines** (DM-2), replacing the\nhard-coded 5-stage enum with first-class `crm_pipelines` + `crm_deal_stages`\ntables. Each stage carries a name, order, weighted-pipeline **probability**, an\noptional **rot threshold**, and **won/closed** flags. Every existing org is\nseeded a \"Default pipeline\" mirroring the legacy stages\n(discovery → proposal → negotiation → won/lost), and every existing deal is\nmapped onto it — all via an idempotent expand/contract migration that leaves the\n`stage` text column in place as a dual-read fallback (no behaviour change yet).\n\nNew read actions **`crm.pipeline.list`** and **`crm.pipeline.get`** (gated by\n`crm:deal:read`, MCP-exposed) expose pipelines + their ordered stages. Editing\npipelines/stages and switching the board to read stages from data land next\n(DM-2b). Covered by unit tests and a PGlite integration test that validates the\nbackfill (default pipeline + 5 stages + deal mapping) end-to-end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-configurable-pipelines-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"292cb6d9-6bb4-4ef8-8e74-ba64553da469","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"clients-invite-portal-user","type":"added","scope":"clients","summary":"CU-1 — `clients.client.invite_portal_user` action provisions a client-portal login for a client's primary contact (or a named one) without leaving the client surface.","body":"`crm.contact.invite_portal_user` exists today but operators\ntypically reach the invite flow FROM a client — they're on\n`/clients/$id` looking at a customer, decide to grant portal\naccess, and want a one-click \"invite the primary contact\" path\nwithout navigating to the contact's own row first.\n\nThis commit adds the client-level convenience action that wraps\nthe CRM action.\n\n**Action: `clients.client.invite_portal_user`** in\n`modules/clients/src/actions/client-portal.ts`.\n\n- Input: `{ id (client/companyId), contactId?, linkUserId? }`.\n  Without `contactId`, the client's `primaryContactId` is used.\n- Output mirrors the CRM action's shape + adds `id` (the\n  companyId) so the caller has the full join key without a\n  follow-up read.\n- Cross-org guard on the company lookup; an additional guard\n  asserts the resolved contact belongs to THIS client (so an\n  operator can't smuggle in a foreign contact via the explicit\n  `contactId` path).\n- Routes through `getAction('crm.contact.invite_portal_user') +\n  invoke()` — keeps the clients↔crm module boundary clean (no\n  `crm/src` imports).\n\n**Event: `clients.client.portal_user_invited`** in\n`modules/clients/src/events/index.ts`. Mirrors the CRM event\nbut carries `companyId` so client-scoped subscribers (the\neventual portal activity feed, AI concierge, downstream\naudit) don't have to re-resolve it.\n\n**Policy** reuses the existing `clientWritePolicy` — operators\nwho can update a client can also issue portal invites for it.\n\n**Tests** — 7 new contract tests (170 total in the module):\nprimary-contact resolution + clients event emission;\nnot_found when no contact; conflict when contactId belongs to\nanother client; cross-org guard; policy denial; linkUserId\nforwarding; service_unavailable when the CRM action isn't\nregistered.\n\nUnblocks **CU-3** (the client-create modal + deal/lead Convert\nprompts) which can now offer a single Grant-portal-access\nbutton without juggling primary-contact resolution in the UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-clients-invite-portal-user.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"0a300c48-c6b6-49cf-9c31-7f0fee10f2d9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"clients-portal-surface-visibility","type":"added","scope":"crm","summary":"Operators can choose which sections (invoices, statement, quotes, projects, support, documents) each client contact sees in the portal.","body":"Added per-contact client-portal surface visibility. From a contact's Portal\naccess panel, an operator with `crm:contact:manage_portal_access` can toggle\nwhich of the six portal sections — Invoices, Statement, Quotes, Projects,\nSupport, Documents — that contact may open. Hidden surfaces disappear from the\nportal nav and their data actions return `policy_denied`, so visibility is\nenforced server-side, not just in the UI. Contacts with no explicit setting see\nevery section (the default stays fully open).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-clients-portal-surface-visibility.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f39c75f5-17b0-49cd-bb2b-a0091d617d50","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-activities-form-polish","type":"changed","scope":"crm","summary":"Polished the CRM \"Log activity\" modal — standard form validation, consistent styling, and a proper empty state.","body":"Brought the CRM **Log activity** modal up to the same bar as the rest of the CRM\nforms (part of the CRM UI polish pass):\n\n- Rebuilt it on the shared `Form` primitives (`FormInput`/`FormSelect`/\n  `FormTextarea`/`FormSubmit`) instead of hand-rolled inputs — so it now has\n  inline validation, the \"link to a contact or company\" rule enforced *before*\n  submit (not just a hint), a tokenized error banner (replacing a raw red box),\n  consistent field/label/spacing, and submit-gated-until-valid.\n- Replaced the bespoke empty state (which used a non-token `text-white` button)\n  with the shared `<EmptyState>` — consistent icon + copy + CTA.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-activities-form-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"f34d8ff1-2df0-444b-96d7-9e3fd175f4a1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-activity-restore","type":"added","scope":"crm","summary":"Added crm.activity.restore to undo a soft-deleted CRM activity.","body":"A soft-deleted CRM activity can now be brought back with **`crm.activity.restore`**\n— it clears `deleted_at` so the entry reappears in the timeline. It's the\ncompensation path for `crm.activity.delete`, gated on the same delete authority\n(`crm:activity:delete`), returns `not_found` if the activity is missing or was\nnever deleted, and emits `crm.activity.restored`. This completes the activity\nlifecycle so create → log → reschedule/cancel → delete → restore all run through\nthe action layer (and so the AI/MCP can undo a mistaken delete), matching the\nrestore symmetry deals, leads, and companies already have.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-activity-restore.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"adf984f4-ce9c-4e10-962a-b0ff31592634","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-activity-worklist","type":"added","scope":"crm","summary":"Added a \"Your tasks\" worklist on the CRM overview — your open, due activities, soonest first.","body":"The CRM home now opens to a **\"Your tasks\"** queue — the activity-based-selling\n\"what to do next\" surface. It lists your own open, due-dated activities (pending\ntasks, calls, meetings) soonest-due first, with overdue items flagged in red and\neach row linking to its deal / lead / contact / company. Tick one off with the\ninline **Done** button and it drops out of the queue.\n\nPowered by a new read-only **`crm.activity.worklist`** query action (org-scoped\nto the caller, requires `crm:activity:read`), so the AI agent and MCP can ask\n\"what's on my plate?\" through the same one path as the UI. Covered by unit\n(policy-deny + validation) and PGlite integration tests (own-only / pending-only\n/ due-dated-only filter + soonest-first ordering + limit).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-activity-worklist.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"0731ab53-4d3e-4d4e-a132-9812c89c6e26","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-filter-selects-shared","type":"changed","scope":"crm","summary":"CRM list filters now use the shared Select component for consistent styling, caret, and focus.","body":"Replaced the hand-rolled `<select>` filter dropdowns on the CRM **Leads** and\n**Companies** lists (status/score, tier/size) with the shared `<Select size=\"sm\">`\nprimitive — so they get the design system's styled caret, tokenized hover/focus\ntreatment, and consistent sizing instead of bespoke inline styles. Part of the\nCRM UI polish pass; the Contacts list filters follow once that file is free of a\nconcurrent edit.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-filter-selects-shared.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ebc75808-e7ef-4e05-9909-6fe3d490d9e9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-polish-round-2","type":"changed","scope":"web","summary":"Phase-17 polish round 2 — find results title-first, webhooks list cleanup, editor action row collapsed into a kebab menu.","body":"Three more layout polishes operators will notice across the\nbusiest CMS admin surfaces.\n\n### `/saas/website/find` — title-first result rows\n\nThe previous result rows put badges + slug + title all on one\nline, with a small external-link icon at the right edge. The\ntitle got lost. The new layout:\n\n- The entire row is a single `<Link>` (the whole card is\n  clickable, not just the title).\n- Title sits on top in its own line at `font-medium`.\n- Badges + slug code chip sit below as a meta strip at\n  `text-xs`.\n- Snippet flows below the meta strip in `leading-relaxed`.\n- Hover lifts the row to `bg-muted/30` + tints the title\n  `text-primary`; the external-link icon fades in.\n\nTightens row spacing from `space-y-3` to `space-y-2` since the\nnew card design is denser.\n\n### `/saas/website/webhooks` — cleaner row hierarchy\n\nThe earlier row had two redundancies: an \"enabled / paused\"\nbadge + an \"Enabled\" checkbox at the bottom for the same\nstate, AND a verbose \"last: HTTP 200\" badge that pushed the\nuseful info off-screen on narrow viewports.\n\nNew layout per row:\n\n- **Row 1** — name + (only-if-paused) `paused` badge + a\n  single status pill (`HTTP 200` / `transport error` / `never\n  fired`) + actions on the right.\n- **Row 2** — URL prominent in a single-line `<code>` block.\n- **Row 3** — meta strip with secret / event count / last\n  fired + a single `Enabled` toggle on the far right (no\n  duplicate badge).\n- Error footer (when present) stays bordered + tinted.\n\nDisabled rows render at `opacity-60` so the eye groups them\ntogether without removing them.\n\n### `/saas/website/$id` — kebab menu for secondary actions\n\nThe editor action row had grown to 11+ buttons in some states\n(undo / redo / preview / open / access / approve / publish /\nreject / request / schedule / translate / save-as-template /\narchive). The 4 lowest-frequency / context-switch buttons are\nnow in a single kebab:\n\n- **Access…** (page ACL admin)\n- **Translate to…**\n- **Save as template…**\n- **Archive page** (danger styling at the bottom of the menu)\n\nPrimary flow buttons (Approve / Publish / Reject / Request\nreview / Schedule) stay inline; preview controls + undo/redo\n+ \"Open in new tab →\" stay inline. The kebab is hidden\nentirely when the actor has zero applicable secondary\nactions.\n\nPure UI; no schema or action changes. Web typecheck + lint\nclean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-polish-round-2.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"c0e69651-0721-4f8b-9190-786aad099de7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-data-driven-deals-board","type":"changed","scope":"crm","summary":"The deals board now renders from the configurable pipeline — data-driven columns, stage moves by id, custom stages.","body":"The deals board is now **fully data-driven** off the configurable pipeline\n(DM-2c), completing DM-2:\n\n- **Columns** come from the org's default pipeline (`crm.pipeline.get_default`) —\n  their order, labels, accent, and per-column value all follow the pipeline\n  config. Reorder or rename a stage and the board reflects it. Falls back to the\n  legacy 5-stage set when no pipeline exists.\n- **Cards group by `stageId`** (not the hard-coded enum), so renamed/custom\n  stages work. `crm.deal.list` and `crm.deal.get` now return `stageId`.\n- **Moves go by `stageId`**: `crm.deal.moveStage` accepts a `stageId` (or the\n  legacy `stage` name) and derives won/lost from the stage's own\n  `isWon`/`isClosed` flags — so a custom \"Closed Won\"/\"Closed Lost\" stage still\n  stamps `won_at`, requires a lost reason, fires `crm.deal.won`/`lost`, and\n  triggers client conversion. Every move keeps `stage` + `stageId` in sync.\n- **KPIs** (open / won / win-rate) are now flag-aware, and the card select +\n  quick-look peek render their stage from pipeline data.\n\nCovered by unit + PGlite integration tests (move by id, won-from-flags,\nlost-requires-reason). With this, DM-2 — configurable pipelines end to end (data\nmodel, migration/backfill, read + full editing actions, and the board) — is done.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-data-driven-deals-board.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e8c929b1-3390-4bd5-81f5-01d2db5a2098","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-board-correctness-fixes","type":"fixed","scope":"crm","summary":"New deals now show on the configurable board immediately, and per-stage stall thresholds are honoured.","body":"Several correctness fixes for the configurable sales pipeline (DM-2):\n\n- **New deals no longer vanish from the board.** A freshly created deal now\n  lands on the org's default pipeline and the stage matching its name, so its\n  card appears in the right column instead of being silently dropped until moved.\n- **Deals can't be stranded across pipelines.** Moving a deal to a stage from a\n  different pipeline is now rejected; valid moves keep the deal's pipeline in\n  sync with its stage.\n- **Per-stage \"stalled deal\" thresholds now apply.** The daily stalled-deal scan\n  reads each stage's configured rot threshold (falling back to the global\n  default) instead of treating every stage the same, and the alert reports the\n  threshold that actually fired.\n- **Board totals no longer collide.** Pipeline value/count KPIs are grouped by\n  stage identity, so two stages that happen to share a name (in different\n  pipelines) keep separate totals.\n- **Stage count cap is enforced on add.** Adding stages now respects the\n  40-stage-per-pipeline limit, preventing a pipeline from growing into an\n  un-reorderable state.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-deal-board-correctness-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a99cdd57-3db0-4e86-a7c6-76f3ef3747f9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-next-activity","type":"added","scope":"crm","summary":"Deals board now flags each open deal's next step — overdue, upcoming, or \"no next step\".","body":"Every open deal on the board now shows its **next step** (activity-based selling):\nthe soonest pending, due-dated activity. Overdue steps are flagged in red, an\nupcoming step shows its date, and a deal with nothing scheduled gets a \"No next\nstep\" warning so reps never let an opportunity go quiet. Terminal (won/lost)\ndeals don't show the signal.\n\nPowered by a new read-only **`crm.deal.next_activities`** query action that takes\na set of deal ids and returns each one's next due activity in a single grouped\nquery (so the AI/MCP can answer \"what's my next move on these deals?\" too).\nCovered by unit (policy-deny + validation + empty) and PGlite integration tests\n(grouped soonest-due, completed/no-due/deleted activities excluded).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-deal-next-activity.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ee7ecd76-3717-4622-93a5-70edf13bcaff","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-pipeline-aggregate","type":"added","scope":"crm","summary":"New pipeline-by-stage report — per-stage deal count and value by currency, for dashboards and the AI.","body":"A new read-only `crm.deal.pipeline` action returns, for each pipeline stage, the\ndeal count and the value bucketed by currency (mixed currencies are never summed).\nIt's computed server-side so it's accurate at any deal volume and respects the\ncaller's scope (`:own` / `:team` narrow it to their deals).\n\nThis is the canonical \"pipeline by stage\" aggregate — the foundation for board\nKPIs and forecasting, and it lets the AI agent answer pipeline questions (\"what's\nmy discovery-stage value?\") directly instead of paging through deals.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-deal-pipeline-aggregate.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"089315a3-cc30-4928-9701-95ecbdc295db","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deals-board-peek","type":"added","scope":"crm","summary":"Click a deal on the board to open a quick-look peek with its details and activity.","body":"Clicking a deal card's title on the board now opens a right-hand **peek** — a\nslide-over showing the deal's key details (stage, amount, probability, owner,\ncompany/contact) and its activity timeline, with an **Open full record →** link.\nYou can triage a deal without leaving the board, then jump to the full record\nwhen you need more. Built on a new reusable `RecordPeek` slide-over that other\nlists and boards can adopt.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-deals-board-peek.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b8358fc2-8a2d-4efc-99c1-0f4b8a55fb3f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deals-board-server-kpis","type":"changed","scope":"crm","summary":"The deals board's KPIs and per-stage totals are now accurate at any deal volume.","body":"The deals board's KPI strip (open pipeline, won, win rate, total deals) and the\nper-column value totals were computed from the first 200 loaded deals — so they\nquietly under-counted on large pipelines. They now come from the server-side\n`crm.deal.pipeline` aggregate, so they're correct no matter how many deals you\nhave, currency-bucketed, and they refresh automatically after a stage move.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-deals-board-server-kpis.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"36da6e76-99b7-47cb-af85-0aa9b52b38a7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-detail-inline-edit","type":"added","scope":"crm","summary":"Edit fields inline on contact, lead, and deal detail pages, with instant optimistic save.","body":"The record detail pages are now editable in place. On a **contact** you can edit\nPhone, Title, and lifecycle Stage; on a **lead**, Status, Title, Phone, and Score;\non a **deal**, Probability, Close date, and Source. Click a value, change it, and\nit saves instantly (optimistic) — reconciling on success and rolling back with a\ntoast on error. Edits are permission-gated (read-only viewers just see the value).\n\nTwo deliberate guards: a deal's **Stage** is still moved from the board (so the\nwon/lost flow — won-stamp, lost reason, client conversion — runs), and a lead's\n**converted** status is reached only via qualify, not a manual edit.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-detail-inline-edit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"eb85703f-3169-4a80-91dd-ed84f09a3a30","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-lead-click-opens-detail","type":"changed","scope":"crm","summary":"Clicking a lead now opens its detail page instead of jumping straight into the edit modal.","body":"Clicking a row in the CRM leads list now opens the **lead detail page**\n(`/crm/leads/$id`) rather than the edit modal — consistent with how contacts and\ndeals behave, so a click is \"look at this lead\" not \"immediately edit it\".\nEditing is still one click away via the row's actions (⋯) menu.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-lead-click-opens-detail.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fc483caa-70d1-4b15-866b-d9be6ac956e2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-move-stage-stageid-sync","type":"changed","scope":"crm","summary":"Moving a deal now keeps its configurable-pipeline stage (stage_id) in sync with the move.","body":"`crm.deal.moveStage` now dual-writes the DM-2 `stage_id` alongside the legacy\n`stage` enum: on every move it resolves the matching stage in the deal's pipeline\nby name and repoints `stage_id` (best-effort — a deal with no pipeline, or a\nstage renamed away from the enum value, simply leaves `stage_id` unchanged rather\nthan failing the move). This keeps the configurable-pipeline data model\nconsistent as deals move, so the upcoming data-driven board (DM-2c) reads correct\nstage assignments. No user-visible behaviour change yet. Covered by a PGlite\nintegration test (normal move + won-close both repoint `stage_id`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-move-stage-stageid-sync.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"18bca53d-a172-4dbc-b6aa-e107ae77e8f9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-performance-metrics","type":"added","scope":"crm","summary":"Added CRM performance metrics — win rate, sales-cycle, lead conversion, and 30-day activity.","body":"The CRM overview now shows a **Performance** card: win rate (won vs lost),\naverage sales-cycle length in days (deal creation → win), lead-conversion rate,\nand trailing-30-day activity volume — the real org-wide answers, computed in SQL\nso they're accurate at any volume (the old dashboard inferred numbers from a\ncapped preview fetch).\n\nPowered by a new read-only **`crm.metrics.performance`** query action (any CRM\nread permission), so the AI agent and MCP can ask \"how's the team doing?\" through\nthe same one path as the UI. Covered by unit tests (policy-deny + empty-org\nnull-rates) and a PGlite integration test asserting each aggregate against seeded\ndata.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-performance-metrics.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"911a6b38-4d79-43ff-8833-147898dccf6c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-pipeline-get-default","type":"added","scope":"crm","summary":"Added crm.pipeline.get_default — the default pipeline + stages the deals board will render.","body":"Added the read action **`crm.pipeline.get_default`** (gated by `crm:deal:read`,\nMCP-exposed): one call returns the org's default pipeline plus its ordered stages\n— exactly what the deals board needs to render columns from data. Returns\n`{ pipeline: null }` when the org has no pipeline configured yet, so the caller\ncan fall back to the legacy stage set. This is the read prerequisite for the\ndata-driven board (DM-2c). Covered by unit (deny + empty-org null) and a PGlite\nintegration test.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-pipeline-get-default.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a44cfc62-dffd-4ad2-8c72-21a1ad91e0d7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-pipeline-manage-permission","type":"added","scope":"crm","summary":"Added the crm:pipeline:manage permission for configuring sales pipelines and stages.","body":"Introduced the **`crm:pipeline:manage`** permission — the admin-level grant for\ncreating, editing, reordering, and deleting sales pipelines and their stages\n(probability, rot threshold, won/closed flags). It is held by Owner and Admin\nroles (and the Sales-manager functional role) out of the box; sales reps work\n*within* pipelines but cannot reconfigure them. The key is now in the catalog so\nthe role editor surfaces it; the pipeline-management actions that consume it land\nwith configurable pipelines (DM-2). This closes the last gap in the permission-\ncatalog reconciliation (the `crm:company:manage` umbrella vs. granular\n`update`/`delete` was already coherent).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-pipeline-manage-permission.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"58381dc8-f054-41cb-a05f-85722c0a9a8b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-pipeline-mutations","type":"added","scope":"crm","summary":"Sales pipelines are now editable — create/rename/set-default plus stage add/edit/reorder.","body":"Building on the DM-2 foundation, sales pipelines and their stages are now fully\n**editable through the action layer** (so the UI, AI agent, MCP, and CLI all use\none path), gated by `crm:pipeline:manage`:\n\n- **`crm.pipeline.create`** — new pipeline, seeded with the 5 standard stages\n  when none are supplied; `setDefault` atomically moves the org default.\n- **`crm.pipeline.update`** — rename, reorder, or set-default (unsetting the\n  prior default in the same transaction).\n- **`crm.pipeline.stage.create`** / **`.update`** — add a stage (probability,\n  rot threshold, won/closed flags) or patch any of its fields.\n- **`crm.pipeline.stage.reorder`** — reorder a pipeline's stages atomically\n  (the id list must be exactly its current stages).\n\nEach mutation emits `crm.pipeline.changed`. Covered by unit (policy-deny +\nvalidation) and PGlite integration tests (create-seeds-stages, set-default\nmoves the flag, stage append/patch, reorder). Switching the deals board +\n`moveDealStage` to read stages from this data (with the enum as fallback) lands\nnext (DM-2c).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-pipeline-mutations.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"37789736-f676-47ac-a89b-b9d11e0e530b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-polish-sprint","type":"changed","scope":"website","summary":"Phase 17 polish — URL-persist the last 3 list-filter routes, ship a daily audit cron, and resolve `snapshotBy` to display names in the revision panel + new Author column on /pending.","body":"Closes the remaining \"operator polish\" items from\n`docs/plans/WEBSITE_CMS_V3_GAP_ANALYSIS_AND_PLAN.md` without\nopening a new design surface.\n\n### 17.B.5 finish — URL-persist filters on 3 more routes\n\nThe original 17.B.5 commit covered 6 routes; this finishes the\nqueue with the remaining 3 that actually had filter state.\n(The other \"9 remaining\" routes from the earlier report turned\nout to carry no list filters — they manage bulk actions or\nedit-form local state only.)\n\n- **`/saas/website/globals`** — `?kind` + `?status`\n- **`/saas/website/media`** — `?view` (active / orphaned / deleted)\n- **`/saas/website/pending`** — `?mine` (assigned-to-me toggle).\n  The legacy localStorage seed stays as a graceful fallback on\n  the bare URL so the filter still survives navigation.\n\n### 17.D / 17.G — daily audit cron\n\n`scan_internal_links` + `scan_dead_sections` actions shipped on-\ndemand only. This commit adds `apps/worker/src/website-audit-cron.ts`\nthat runs both sweeps daily across every org with a CMS page,\nemitting structured `logger.warn` lines on any findings so\ncontent rot gets noticed even when nobody opens the audit\nsurface. Per-org per-scan failures isolated via the existing\nresult envelope. Stagger 30 min post-boot to avoid the media-\nscrub (10 min) + tag-usage (20 min) crons.\n\n### 17.B.8 — `snapshotBy` resolved + Author column\n\n**`website.page.list_revisions`** now leftJoins `users` so\nthe response carries `snapshotByName` + `snapshotByEmail`\nalongside the existing uuid. The editor's revision panel\nrenders `· by <Name>` instead of a uuid suffix; falls back to\nemail then \"system\" so the line always reads naturally.\nDeleted users gracefully degrade because of the leftJoin.\n\n**`serializePage`** now includes `createdBy`. The `/pending`\nqueue gets a new \"Author\" column that resolves the page's\n`createdBy` via the org-members map already fetched for the\nexisting \"Assigned\" column — no second round-trip. Falls back\nto a short uuid prefix when the author isn't in the members\nlist (e.g. they left the org).\n\n`PageSummary.createdBy` defaults `null` so existing consumers\nthat ignore the field aren't broken.\n\n### Tests + checks\n\n325 / 22 website tests stay green. Web + website typecheck\nclean. Three deferred items from the earlier audit close:\n17.B.5 (now 100%), 17.B.8, and 17.D.3 cron half + 17.G.3 cron\nhalf (the action-only halves shipped in earlier commits).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-polish-sprint.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"cb4b8b37-34cf-49f9-9340-936166737dc5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-pipeline-stage-delete-actions","type":"added","scope":"crm","summary":"Sales pipelines and stages can now be deleted, with their deals safely reassigned to a fallback.","body":"Pipeline admins (`crm:pipeline:manage`) can now remove a pipeline stage or a\nwhole pipeline — the missing counterpart to create/update/reorder. Both are\nguarded, dangerous actions:\n\n- **Delete a stage** (`crm.pipeline.stage.delete`) moves every deal on that\n  stage to a caller-chosen fallback stage in the same pipeline, then retires the\n  stage. The last remaining stage of a pipeline can't be deleted.\n- **Delete a pipeline** (`crm.pipeline.delete`) refuses to remove the org\n  default (set another default first). If the pipeline still holds deals, a\n  fallback pipeline is required and the deals move to its first open stage before\n  the pipeline and its stages are retired.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-crm-pipeline-stage-delete-actions.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"12964d75-f6a7-4a01-b165-e2906fb84acc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"focus-mode","type":"added","scope":"web","summary":"Added a focus mode that tucks away the icon rail, revealed on edge-hover.","body":"A new **focus mode** trims the chrome for distraction-free work: the icon\nrail tucks away so the workspace gains horizontal room, while the module\nsidebar stays put so the active module's navigation is always visible. The\nrail is never more than a gesture away — sweep the cursor to the left edge\nand it floats back in, then slides away when you move off it.\n\nToggle it from the new control in the top bar or with **⌘\\\\** (desktop). The\npreference is remembered across reloads. On mobile nothing changes — the rail\nis already a drawer there.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-focus-mode.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9a0ba08e-7634-4522-87fb-d76afa998e5e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"iam-user-link-picker-extracted","type":"changed","scope":"web","summary":"FND-4 — extract the HRM \"No user / Link existing / Create new\" picker into a shared `<UserLinkPicker>` for CRM contacts + clients to consume.","body":"The HRM directory's hire form ships a polished \"No user /\nLink existing / Create new\" picker for tying a new employee\nto a `users` row (or skipping the link entirely). It was\nembedded as 200+ lines of forms-foundation work inside\n`apps/web/src/routes/hrm/directory.index.tsx` — invisible to\nCRM contact + clients flows that want the same shape.\n\nThis commit (FND-4 of CLIENTS_PORTAL_BUILD_AND_POLISH_PLAN)\nextracts the component into\n`apps/web/src/components/iam/user-link-picker.tsx` and\nrefactors HRM to consume the shared version. Pure refactor:\nno behaviour change to the HRM hire form, no new tests.\n\nKey generalizations vs the HRM original:\n\n- **`linkedTo: { id, label } | null` per-user** — HRM passed\n  per-user `linkedEmployee`; the shared shape is opaque so\n  future consumers (`crm_contacts.userId`, project-member\n  links) plug in without picker changes. HRM uses a `.map()`\n  adapter at the consumer site to keep the same employee\n  copy (\"already Alice (#E-123)\").\n- **`currentLinkId` (was `currentEmployeeId`)** — generic\n  domain-record id, drives the disabled-not-self pattern.\n- **`allowedRoles?: readonly UserLinkRole[]`** — HRM passes\n  the org-membership ladder (`viewer | member | manager |\n  admin | owner`); CRM contact / client flows can pass\n  `['client']` so the picker can only emit a client-portal\n  invite. Defaults to the org ladder.\n- **`copy?: { ... }`** — per-consumer string overrides for\n  tab labels, the unlink warning, the invite explainer, and\n  the role-section label. HRM threads its existing `tt()`\n  translations through.\n\nThe new `UserLinkRole` type is a superset\n(`'owner' | 'admin' | 'manager' | 'member' | 'viewer' |\n'client'`) — HRM narrows on assignment + restricts the\npicker via `allowedRoles` so the legacy `MembershipRole`\ntype stays the source of truth on its side.\n\nUnblocks the remaining client-portal queue items:\n- **CU-3** (prompt on client create + convert-from-deal/lead)\n- the existing CU-4 (contact-form portal toggle) which can\n  upgrade from its current boolean checkbox to the richer\n  three-mode picker.\n\nPure UI; web typecheck clean. No changelog scope changes;\n`scope: web` because this is an apps/web component shipped\nwithout action / schema work.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-iam-user-link-picker-extracted.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"874dfaff-b69f-4eae-9c82-5b74a1f32ff3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-pay-page-org-branding","type":"added","scope":"payments","summary":"The checkout pages now show the paying org's own branding when its plan allows custom branding, otherwise the platform's.","body":"The public payment pages (`/pay/c`, `/pay/return`) now resolve branding\nper-session: when the paying org's plan grants the `custom_branding` feature,\nthe page shows that org's name, logo, and brand colour; otherwise it shows the\nplatform brand. A new public `payments.session.branding` action does the\nresolution (the session id is the auth), and the page renders a brand-coloured\naccent. The token-based portal page keeps the platform brand. An unbranded\ndeployment shows no name — never a hard-coded codename.\n\nTo grant custom-branded checkout to a plan, set `custom_branding: true` in that\nplan's features.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-pay-page-org-branding.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b9c31bb3-f3ef-48f2-ac74-06cf8b1e99f7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-allow-test-mode-staging","type":"fixed","scope":"payments","summary":"Sandbox payment providers can now be used on staging/demo deployments via PAYMENTS_ALLOW_TEST_MODE.","body":"The \"is this production?\" check that forbids sandbox providers was a bare\n`NODE_ENV === 'production'`. But every built deployment — staging, demo, a\nself-hosted trial — runs with `NODE_ENV=production`, so on those boxes a\ntest-mode provider could not be set as the org default and was never selected\nby routing, even after adding a catch-all rule. The result read as \"no default\npayment provider\" with no way out.\n\n`isProductionEnv()` now honors an explicit escape hatch: set\n`PAYMENTS_ALLOW_TEST_MODE=1` on a non-live deployment to allow sandbox\nproviders as the default and as routing targets. A real live-money production\ndeployment leaves it unset, so test gateways are still never the default and\nnever receive a live charge. The provider-default and routing-failure error\nmessages now point operators at this flag.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-allow-test-mode-staging.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"02a1df9b-1ad9-4d01-b96a-68d568683fce","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-checkout-reliability","type":"fixed","scope":"payments","summary":"Invoice payments now self-host the Stripe checkout, return cleanly, and reliably mark the invoice paid even without a webhook.","body":"Three connected fixes to the pay-by-link flow:\n\n- **Self-hosted checkout.** Invoice payment links now default to the inline\n  Stripe Elements form on our own branded page instead of redirecting to\n  Stripe's hosted page. Providers that can't do Elements fall back to hosted\n  automatically.\n\n- **Clean return.** The post-payment redirect now lands on the token-free\n  `/pay/return/<sessionId>` confirmation page. The old default returned to the\n  token-gated invoice URL, which showed \"Missing share token\". (Also fixes a\n  latent `__SESSION_ID__` placeholder that was never substituted.)\n\n- **Reliable recording.** The payment was only ever recorded by the provider\n  webhook — so if the webhook was delayed or not configured, the money was\n  taken but the invoice stayed `issued`. The `/pay/return` confirm now asks the\n  provider directly (new `intentRetrieve` adapter method) and records the\n  charge itself, fully idempotent with the webhook (charge insert is\n  conflict-safe; success events fire only for the writer that records it).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-checkout-reliability.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6a5de6d7-b773-4c08-b8f3-d85e82c4eff8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-checkout-ui-polish","type":"changed","scope":"payments","summary":"The public checkout page got a cleaner, more modern layout.","body":"The `/pay/c` checkout page now leads with a prominent \"Amount due\" summary in a\nsingle rounded card, separates it from the payment form, and uses a proper lock\nglyph on the security note instead of an emoji. Combined with the brand-coloured\naccent and per-org branding, the page reads as a polished, trustworthy checkout.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-checkout-ui-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"08e7cd5c-2c7d-4933-abb8-5a1e0e886f03","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-connect-refund-reverses-fee","type":"fixed","scope":"payments","summary":"Refunding a Stripe Connect charge now reverses the platform's application fee instead of keeping it.","body":"When a charge taken on a connected account carried a platform application fee\nand was later refunded, the platform kept its cut even though the customer got\ntheir money back. Connect refunds now pass `refund_application_fee` so Stripe\nreverses the application fee proportionally to the refunded amount (a no-op when\nthe charge had no fee). Direct (non-Connect) refunds are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-connect-refund-reverses-fee.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b08425d1-5d7f-4e5f-aeea-a640a83ef0b0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-customer-ensure-race","type":"fixed","scope":"payments","summary":"Concurrent customer ensure-or-create no longer errors on a race; it returns the existing customer.","body":"`payments.customer.ensure` deduped with a pre-check, but two concurrent calls\nfor the same (provider, external user / client) could both pass it and the\nloser hit the unique-index violation as an unhandled error. It now catches the\nviolation and returns the existing customer (`isNew: false`), matching the\nintent-create fix.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-customer-ensure-race.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5b4507bf-87e7-4a36-b499-228bd04a951e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-dispute-lifecycle-reversal","type":"added","scope":"payments","summary":"A lost chargeback now automatically reverses the invoice payment, so the invoice no longer shows as paid.","body":"Until now a dispute only raised an alert; when the chargeback finalized as\n**lost**, the funds were clawed back by the bank but the invoice stayed `paid`,\noverstating revenue. The payments module now emits `payments.dispute.closed`\nwith the won/lost outcome, and Sales reverses the recorded payment on a loss\n(invoice drops back to `issued`/`partial`, mirroring a refund). A `won` dispute\nkeeps the funds and changes nothing. The reversal is idempotent on the dispute\nid, so a re-delivered webhook reverses exactly once.\n\nA chargeback that exceeds the payment's remaining refundable amount (e.g. after\na prior partial refund) is logged for manual reconciliation rather than guessed\n— and a dispute later overturned on representment requires re-recording the\npayment manually.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-dispute-lifecycle-reversal.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"197b4650-a2d9-4ed5-b813-8013d6ad2f51","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-geo-routing-phase0","type":"added","scope":"payments","summary":"Foundation for country/region-aware payment routing — the country matcher and specificity scoring.","body":"First step of geo-aware payment routing (see docs/plans/PAYMENTS_GEO_ROUTING_SPEC.md):\na country → region taxonomy (`COUNTRY_TO_REGIONS`) and a `countryPatternMatches`\nmatcher that recognises `'*'` (any), an exact ISO code (`'US'`), or a region\nprefix (`'EU-*'`). The routing specificity scorer now understands a country\ndimension (exact +100, region +75, below currency and above amount bounds).\nPure functions only — no schema change and no behaviour change yet; the\nresolver starts consulting country in a later phase.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-geo-routing-phase0.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"77ddbb1b-06d4-47fc-9ad1-41c7de2a960a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-intent-idempotency-race","type":"fixed","scope":"payments","summary":"Concurrent payment-intent creation with the same idempotency key now returns the existing intent instead of erroring.","body":"`payments.intent.create` deduped on `(org_id, idempotency_key)` with a\npre-check, which covers a sequential retry — but two *concurrent* calls with\nthe same key could both pass the pre-check, both reach the insert, and the\nloser hit the unique-index violation as an unhandled error. It now catches that\nviolation and returns the intent the winner wrote (`isNew: false`), so a\ndouble-submit/retry is a clean no-op. The provider side was already safe (the\nadapter forwards the idempotency key to the provider).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-intent-idempotency-race.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"4b9035fc-31b2-4dab-a12f-90f130daf4f9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-public-invoice-pay-return","type":"fixed","scope":"sales","summary":"Paying a shared invoice from its public page no longer dead-ends on \"Missing share token\".","body":"The \"Pay now\" button on a public shared invoice (`sales.invoice.public.pay`)\nredirected to the provider's hosted page and returned the buyer to\n`/i/<id>?paid=true` — without the share token — so they hit \"Missing share\ntoken. This link is incomplete.\" (This was a second payment path; the\noperator-generated link was already fixed.) It now defaults to the self-hosted\nElements checkout (the buyer stays on our branded page and lands on the\ntoken-free `/pay/return` confirmation), and for the hosted-provider fallback it\npreserves the share token on return so the buyer lands back on the paid invoice.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-public-invoice-pay-return.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7faae6c2-3f4e-470c-9c87-a1233c591197","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-test-mode-allowed-by-default","type":"fixed","scope":"payments","summary":"Sandbox/test-mode payment providers now work as the org default and routing target everywhere, with no setup.","body":"Connecting a sandbox/test-mode provider (e.g. Stripe test keys) and trying to\nuse it left operators stuck: it couldn't be set as the org default and was\nnever selected by routing — so sales and other flows reported \"No payment\nprovider is configured\" even after adding a catch-all routing rule. The block\nkeyed off `NODE_ENV=production`, which every real deployment (staging, demo,\nself-hosted) runs, and the earlier env-var / DB-toggle escape hatches were\nfragile (a missing migration could even make the routing read throw).\n\nSandbox providers are now **allowed by default, everywhere** — they're badged\n\"test mode\" across the UI, and a real charge routed to a sandbox fails loudly\n(sandbox gateways reject live cards) rather than losing money, so blocking them\noutright only broke the normal setup-and-test flow. A deployment that wants a\nhard live-only guarantee sets `PAYMENTS_STRICT_LIVE_MODE=1`, which forbids a\ntest-mode default and makes routing skip test providers. The policy no longer\nreads the database, so it can never fail the routing path. The earlier\n`platform_settings.payments_allow_test_mode` toggle + `PAYMENTS_ALLOW_TEST_MODE`\nenv var are now deprecated no-ops.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-test-mode-allowed-by-default.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6cadfcb4-97ca-4f16-ad7e-f2a5466a2c00","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-test-mode-ui-toggle","type":"added","scope":"payments","summary":"A platform toggle lets operators allow sandbox payment providers as default + routing target — no env var, no restart.","body":"Allowing sandbox/test-mode providers to be the org default and be routed used\nto depend on `NODE_ENV` (and the `PAYMENTS_ALLOW_TEST_MODE` env var) — which\nforced operators on a staging/demo deployment to edit env and restart, and was\neasy to get wrong. There is now a **Sandbox / test-mode providers** switch in\nSaas → Platform → Payments (root only). Flip it on and sandbox providers can be\nthe default and receive routed charges across the deployment — applied\nimmediately, no restart. It is off by default (live-only), so a real production\ndeployment never routes a live charge to a sandbox gateway until an operator\nopts in. The provider guards, the provider-form default toggle, and the routing\nresolver all read this one setting (the env var remains as a back-compat hatch).\n\nAdds `platform_settings.payments_allow_test_mode` (migration\n`0220_0221_platform_payments_allow_test_mode`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-payments-test-mode-ui-toggle.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"852b044e-c519-4530-a980-81cbf621c29c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"pdf-polish-round2","type":"changed","scope":"sales","summary":"Invoice PDFs gain a courtesy closing line and statement PDFs get zebra-striped ledgers.","body":"A second round of PDF polish:\n\n- **Invoice closing line** — a quiet, centred courtesy note now prints above the footer on live invoices: \"Paid in full — thank you for your business.\" when settled, or \"Please remit payment by <due date>. Thank you for your business.\" when a balance remains. Void / written-off invoices keep their stamp instead.\n- **Statement zebra striping** — the statement-of-account ledger now uses the same barely-there alternating-row tint as the invoice line tables, so long ledgers are easier to scan. The balance-due card colour was also moved into the shared token table.\n\nPresentational only. Added a statement-PDF render smoke-test (the statement template previously had none), bringing the PDF render coverage to invoice, quotation, credit note, and statement.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-pdf-polish-round2.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"fc4d985a-4226-4355-8a18-604cd038e301","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-action-rate-limits","type":"security","scope":"web","summary":"Tightened rate limits on client-facing portal actions (login invites, uploads, payments, quote responses).","body":"Hardened the per-(IP, action) rate limiter for the externally-reachable client\nportal. Granting a portal login (`clients.portal.team.invite`) is now capped like\nother account-provisioning endpoints (10/min), and the portal's document-upload\npresign/confirm, online payment initiation, and quote-response actions get a tight\n30/min cap — generous for a real client, hostile to a scripted external caller. The\nexisting global per-IP ceiling still applies on top.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-portal-action-rate-limits.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"59bd02b2-0dd0-4592-a55e-17e291353b34","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"projects-require-client","type":"changed","scope":"projects","summary":"Every project now belongs to a client — required when creating, editable in settings, with existing projects backfilled to a default client.","body":"Projects are now always connected to a client (SURF-4 foundation). Creating a\nproject requires picking a client (a new required Client picker in the New Project\nsheet), and the client is reassignable later from Project → Settings. Existing\nprojects were backfilled: a one-time migration seeds an \"Internal projects\" default\nclient per organisation and links every previously client-less project to it, so no\nproject is left unassigned. The link is the canonical CRM pointer used across the\napp (client detail → Projects tab, and the upcoming client portal project view).\nAutomated template-spawn paths (deal-won, onboarding, fulfilment) are unaffected for\nnow and will be wired to pass a client in a follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-projects-require-client.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"63f46aec-ea2e-473f-9b11-d17394219cf7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"quotation-pdf-validity-line","type":"changed","scope":"sales","summary":"Open quotation PDFs now print a \"valid until <date>\" line, matching the invoice closing line.","body":"A still-open quotation / estimate / proposal PDF now prints a quiet closing line above the footer — \"This quotation is valid until <expiry date>.\" (using the document's configured noun) — so the recipient sees the deadline at a glance. Terminal states (accepted / declined / expired / cancelled) carry their status stamp instead, so the line is suppressed there. Mirrors the invoice closing line added in the same release. Covered by a render smoke-test.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-quotation-pdf-validity-line.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"b3dd983e-908f-45b1-8375-62f542c4d927","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-quote-accepted-owner-alert","type":"fixed","scope":"sales","summary":"The quote owner is now notified the moment a customer accepts a quotation (in-app + email).","body":"Closed a notification gap: when a customer accepts a quotation — from the public\nlink or the client portal — the rep/owner now gets an in-app notification and email\n(\"Quotation X accepted — time to invoice\"), the same way they already do for\ndeclined and expired quotes. The owner-facing template and flow already existed but\nnothing was firing them; added the `sales.quotation.accepted` subscriber that\nresolves the quote owner (falling back to the linked deal owner) and dispatches.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-sales-quote-accepted-owner-alert.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"03ca44a7-c199-4ab9-a5d6-4c0e011a33ae","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-refund-external-ref-idempotency","type":"fixed","scope":"sales","summary":"A re-delivered partial provider refund can no longer be recorded twice against an invoice.","body":"`sales.payment.refund` deduped on its `externalRef` (the provider refund id)\nonly via an out-of-transaction pre-check in the gateway subscriber. Two\nconcurrent re-deliveries of the same **partial** provider refund could both\nclear that pre-check, serialize on the invoice lock, and both pass the\nrefund cap (the remaining balance was still positive after the first) — so the\nrefund was recorded twice and the invoice over-credited. (Full refunds were\naccidentally safe: the second hit the \"already fully refunded\" cap.) The action\nnow re-checks `externalRef` under the invoice lock, mirroring\n`sales.payment.record`'s reference dedup, so the first write wins and any\nre-delivery returns it unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-sales-refund-external-ref-idempotency.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ef461a0a-50f4-47ba-94e0-e93ceef6f698","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"settings-sidebar-reorg","type":"changed","scope":"web","summary":"Reorganized the Settings sidebar into six collapsible, clearly-named groups.","body":"The Settings sidebar used to pile eleven items into a single \"Workspace\"\nsection and render every group flat. It's now a collapsible tree (matching\nthe SaaS console) with six balanced, intuitively-named groups: **Account**,\n**Workspace**, **Billing & payments**, **Communication**, **Localization**,\nand **AI**.\n\nEach group expands on click and the group holding the page you're on opens\nautomatically, so a long flat scroll becomes a quick scan. Currencies now\nsits with Billing & payments (it's money config), Email and the notice board\nshare a Communication group, and Access / Modules / Catalogs live under\nWorkspace alongside the org identity. Every link is unchanged — only the\ngrouping moved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-settings-sidebar-reorg.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ef2d7fd3-13c9-4889-9b13-c159e0e094a1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"support-ticket-requester-access-guard","type":"security","scope":"support","summary":"Support ticket read/reply now enforce per-row ownership, closing a cross-requester access gap.","body":"`support.ticket.get`, `support.ticket.reply`, and `support.ticket.message.list`\npreviously scoped only by organization — an actor holding a requester-level read\nscope (e.g. `support:ticket:read:own`, as the `client` role does) could read or\nreply on **any** ticket id in the org, not just their own.\n\nThese actions now apply a per-row access guard (`actorMayAccessTicket`): agents\nwith org-wide or team read scope are unaffected; a requester-only actor must be\nthe ticket's requester or assignee, or — for client-org-admins / portal\ncontacts — on the ticket's requester company (resolved by email or the\n`crm_contacts.user_id` portal link). Unauthorized access returns `not_found`\n(existence is not leaked).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-support-ticket-requester-access-guard.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"215529f7-365a-4ae4-a794-59735f3bf4b5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-ai-action-card","type":"added","scope":"ai","summary":"Added the AiActionCard primitive — an Approve/Edit/Deny preview card for AI-proposed actions.","body":"Added `<AiActionCard>` to `@helios/ui` — the preview card the design system\nrequires for AI-*proposed mutations* (\"proposed actions from AI render as preview\ncards with Approve/Edit/Deny\", `.claude/rules/ui.md`), so nothing the agent wants\nto write lands without a human in the loop. It wraps the proposed action in the\n`AiContent` purple treatment, renders an optional change preview, and offers\nApprove / Edit / Deny; destructive proposals flip Approve to the danger variant.\nReusable by every module's AI surface. Together with `AiContent` this completes\nthe reusable AI-content marking convention the Phase-7 AI features will build on.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-ui-ai-action-card.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"3dabeaeb-a2ae-426b-99a4-5c2581d558d4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-ai-content-primitive","type":"added","scope":"ai","summary":"Added the AiContent UI primitive — the canonical purple-left-border wrapper for AI-generated content.","body":"Added `<AiContent>` to `@helios/ui` — the reusable wrapper that gives\nAI-generated / AI-suggested content the design system's mandated **subtle purple\nleft border + tint** (DESIGN.md §AI surfaces; the UI rule \"AI-generated content\nhas a subtle purple left border\"). It pairs an optional eyebrow label with the\nexisting `AiDot` marker (static or breathing) so every module renders the AI\nconvention identically — CRM AI fields, ask-CRM answers, drafted emails — instead\nof each surface hand-rolling its own treatment. Proposed mutations should still\npair it with an Approve/Edit/Deny affordance so nothing the AI writes lands\nsilently.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-ui-ai-content-primitive.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"222dbc02-80b8-4ae4-979f-f41dc338a26e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-new-primitives","type":"added","scope":"ui","summary":"Eight new shared UI primitives — Spinner, Alert, Breadcrumb, Popover, RadioGroup, ScrollArea, Accordion, ToggleGroup.","body":"Filled real gaps in the shared component library (`@helios/ui`) so feature work\nstops hand-rolling these. All match the design system (one focus language, the\ntokens, restrained motion, the frosted floating-surface recipe) and ship fully\nkeyboard-accessible:\n\n- **Spinner** — standalone loading indicator (the Button's inline arc, reusable).\n- **Alert** — inline, persistent callout (info / success / warning / danger / AI /\n  neutral) with optional title, dismiss, and action — the non-transient counterpart\n  to a toast.\n- **Breadcrumb** — navigation trail with a router-aware link slot.\n- **Popover** — a general-purpose floating panel (filters, mini-forms) sharing the\n  frosted overlay look with menus and tooltips.\n- **RadioGroup / Radio** — accessible single-select built on native inputs.\n- **ScrollArea** — custom overlay scrollbars matching the app's hover-reveal style.\n- **Accordion** — animated, accessible disclosure list (collapsed panels leave the\n  tab order via `inert`).\n- **Toggle / ToggleGroup** — two-state buttons and a single/multi-select group (the\n  multi-select companion to the single-select Segmented control).\n\nNo new runtime dependencies — Popover and ScrollArea wrap Radix packages already in\nuse; the rest are dependency-free.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-ui-new-primitives.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a67bdafc-9592-4a68-9076-538dc56ac87f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-primitive-premium-craft-pass","type":"changed","scope":"ui","summary":"Premium-craft polish across UI primitives — one focus ring, tactile button press, and frosted overlays.","body":"A consistency + craft pass on the shared UI primitives (`@helios/ui`) to bring the\nwhole app to a competitor-grade premium feel — without an overhaul, gradients, or\nglass-slop. No new components, no route changes.\n\nWhat changed for users:\n\n- **One focus ring everywhere.** Buttons, icon-buttons, checkboxes, switches,\n  tabs, segmented controls, pagination, menu items and links now share a single\n  focus treatment (a crisp knockout ring that follows each control's shape).\n  Typeable fields (input, select, textarea, pickers) share one quieter ring.\n  Previously six different focus styles fought each other — and several controls\n  drew two rings at once.\n- **Tactile buttons.** Buttons and icon-buttons now press in slightly on click\n  (GPU-only, respects reduced-motion).\n- **Frosted overlays with depth.** Menus, tooltips, the ⌘K command palette and\n  picker popovers share one floating-surface look; modal/sheet backdrops now\n  gently blur the page behind them. Content surfaces stay solid — blur is used\n  only on transient overlays.\n- **Field consistency.** A Picker now matches an Input/Select sitting next to it\n  in the same form (same border at rest, same hover, same focus).\n\n- The active-tab underline now uses the shared spring (slightly more bounce\n  than before) so its motion matches the segmented control.\n- **Colored borders now render.** A long-standing layering bug meant the\n  app-wide default-border rule silently overrode every `border-[color]` /\n  `hover:border-*` / `focus:border-*` utility, flattening intended colored and\n  hover borders to the plain hairline. Moving that default into Tailwind's base\n  layer lets those borders render as designed — hover states on buttons/cards,\n  tinted badge borders, and field hover/focus borders now actually show. Bare\n  (uncolored) borders are unchanged.\n\nUnder the hood: canonical `--focus-ring` / `--focus-ring-field` / `--bg-overlay`\n/ `--blur-*` tokens; field focus is owned centrally by `globals.css` (Tailwind v4\nutilities are layered and can't override the unlayered base, so the field ring\nlives there); `tokens.ts` + `motion.ts` re-synced to `globals.css`; dead token\nrefs and hex fallbacks removed; `docs/DESIGN.md` updated to the real token values\nplus a documented focus model and anti-slop guardrails.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-ui-primitive-premium-craft-pass.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"63322582-6e45-4312-a557-ef8469391de4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-acl-wire-and-ui","type":"changed","scope":"website","summary":"Phase 17.G.3.b/c — per-page ACL gate wired into the remaining edit-flow verbs + admin dialog on the page editor.","body":"Phase 17.G.3 shipped the ACL data layer + grant/revoke/list\nactions + wired the gate into `updatePage` / `publishPage` /\n`archivePage`. This commit closes the loop on the remaining\nedit-flow verbs and adds the operator surface.\n\n**ACL wire-in for the rest of the lifecycle**:\n- `requestReviewPage` — requires `editor` (it just hands a\n  page off to a reviewer; doesn't publish).\n- `rejectReviewPage` — requires `approver` (reject is the\n  inverse of approve; only people allowed to publish are\n  allowed to refuse to publish — otherwise an editor could\n  block an approver by repeatedly rejecting).\n- `restorePage` — requires `approver` (un-archiving is\n  symmetric with archive).\n- `batchPublishPages` — per-row `approver` check; failures\n  isolate via the existing per-row error envelope so the rest\n  of the batch still runs.\n- `batchArchivePages` — per-row `approver` check.\n- `batchSetStatusPages` — per-row `approver` check (both\n  directions; either transition materially changes\n  publication state).\n\n**Admin UI** — new `PageAclDialog` component\n(`apps/web/src/components/website/page-acl-dialog.tsx`)\nopened from a new \"Access…\" button in the page-editor action\nrow. Renders only when the actor holds\n`platform:website:page:acl:manage`.\n\n- Lists current ACL entries with user name + email + role\n  badge (info for `editor`, success for `approver`) + grant\n  date + per-row Revoke button.\n- Grant form: user-id input (uuid-validated client-side) +\n  role select + Grant button. Cross-org guard enforced by the\n  action; UI just shortens the round-trip.\n- Empty-list copy explains the fallback: \"No ACL entries.\n  This page is open to anyone with the org-wide permissions.\"\n- Revoke uses a separate ConfirmDialog with the danger tone\n  + a warning that removing the last entry reverts to org-\n  wide permissions.\n\nPure UI for the dialog; no schema or action changes. The\nremaining handler wire-ins reuse the existing\n`checkPageAcl()` helper from 17.G.3 — backwards-compatible\n(zero ACL rows on a page → unrestricted; legacy behaviour).\n\n325 / 22 website tests stay green (no new tests; existing\nhandler tests still pass via the defensive ACL filter in\n`checkPageAcl` that ignores non-ACL-shaped stub rows). Web\ntypecheck clean.\n\nCloses Phase 17.G.3 end-to-end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-acl-wire-and-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"48236cc7-50fb-4bc3-803b-546a8f06b31c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-admin-polish","type":"changed","scope":"web","summary":"Layout polish across all /saas/website/* admin routes — sticky WebsiteAdminNav, h1 spacing cleanup, audit summary card upgraded to a 3-up stat grid.","body":"Three small layout consistency fixes operators will notice\nacross every CMS admin route.\n\n### WebsiteAdminNav now sticks on scroll\n\nThe horizontal pill bar at the top of every `/saas/website/*`\nroute now stays in view on long pages (sticky `top-0`,\nbackdrop-blur background, subtle bottom border). The pill\nspacing tightens to `h-7 py-1.5`; the active pill gets a\nshadow and `aria-current=\"page\"` for screen readers. Hover\nstate on inactive pills bumps from `bg-muted/30` to\n`bg-muted/40` for slightly more contrast.\n\n### h1 vertical-spacing cleanup\n\nWhen FND-4 removed the `← Back to website` link from every\nroute header (the WebsiteAdminNav now covers that\naffordance), the `mt-1` on the `<h1>` was left behind on 14\nroutes — adding a stray gap above the page title. This\ncommit strips the leftover class so every header sits flush\nwith the section above it.\n\nFiles touched: `website.archived` / `blog` / `collisions` /\n`globals` / `inventory` / `media` / `pending` / `presets` /\n`redirects` / `scheduled` / `sections` / `settings` /\n`templates` / `translations`.\n\n### `/saas/website/audit` — uniform stat tiles\n\nThe two summary cards (broken links + dead sections) were\nrendered as wrapping `flex` spans with inline numbers. They\nnow render as a `grid grid-cols-3 gap-4` of `AuditStat`\ntiles — a big number on top, a small label below. The \"Issues\"\ntile shifts to `text-warning` when findings exist (operators\nwant their eyes drawn there) and `text-success` when clean\n(boring is the right outcome).\n\nPure presentation; no schema or action changes. Web typecheck\n+ lint clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-admin-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"29fc202e-db43-456d-abaa-858bc00d4186","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-editor-layout-fixes","type":"fixed","scope":"web","summary":"Page builder no longer overflows the viewport and stops squeezing the editor column when the preview pane is open.","body":"Four real layout bugs in `/saas/website/$id`, found by direct\ninspection after the operator reported \"page builder layout\nbroken.\" All concrete, all visible on a normal browser at common\nviewport sizes.\n\n### 1. Outer grid was 8px taller than the viewport\n\nThe 2-col split (editor | preview) used\n`lg:h-[calc(100vh-3.5rem)]` to leave room for the app topbar.\nBut the topbar is `sm:h-12` (48px / 3rem), not 56px / 3.5rem.\nThe 8px overshoot pushed the grid cell past the viewport\nbottom, which `lg:overflow-hidden` then clipped — taking the\npreview's last scroll-line + the editor's bottom shadow with\nit. Fixed: `calc(100vh-3rem)`.\n\n### 2. Preview column was greedy → editor squeezed\n\nThe grid columns were\n`minmax(0,1fr)_minmax(420px,55%)`. On a 1280px viewport that\nhands the preview 704px and leaves 576px for the editor; on a\n1024px viewport the preview still grabs 563px and the editor\ncollapses to 461px — too narrow for the per-card action row\n(8 icon buttons) without wrapping awkwardly through the title.\n\nNew shape: `minmax(380px,1fr)_minmax(380px,1fr)` — equal\nsplit with a sane minimum on both sides. Both panes get\n~620px on a 1280px viewport instead of one being starved.\n\n### 3. PreviewPane was taller than its grid cell\n\n`<PreviewPane>` used `lg:sticky lg:top-0 lg:h-screen`.\n`h-screen` is 100vh, but the cell is now `calc(100vh-3rem)`,\nso the pane overflowed the cell by 48px and got clipped at the\nbottom by the cell's `overflow-hidden`. Worse, `sticky` inside\nan `overflow-hidden` parent does nothing — sticky needs a\nscroll container.\n\nFixed: `lg:h-full` (fills its cell) and dropped the bogus\n`sticky top-0`. The pane is already a flex column with its\ntoolbar pinned at the top + iframe stretching to fill, so\nnothing else needed to change.\n\n### 4. Inner editor butted against the cell edge\n\nThe grid-cell wrapper had `lg:px-6 lg:py-8`, fine — but the\ninner editor container went all the way to `lg:px-0 lg:py-0`,\nmeaning form labels and inputs sat 0px from the cell's left\nedge after the cell's px-6 ran out. Bumped to `lg:px-2 lg:py-1`\nso fields have a breathing strip.\n\n### Bonus polish\n\n- Single-column mode (preview closed) was capped at `max-w-3xl`\n  (768px) — fine for a doc reader, narrow for an editor with\n  title + meta + 14-block visual editor + revision history side\n  by side. Bumped to `max-w-5xl` (1024px) so operators who\n  rarely open the preview get a roomier canvas. Mobile padding\n  preserved via `px-4 lg:px-6`.\n\n- Per-card action toolbar (`Move-up | Move-down | Note | Insert |\n  Change-type | Duplicate | Save-as-global | Remove` — up to 8\n  icon buttons) was `flex shrink-0` so it never wrapped. Added\n  `flex-wrap justify-end` so when the title row's\n  badges + summary need more space, the action row flows onto a\n  second line under the badges instead of pushing the title\n  off-screen.\n\nPure layout fix; no schema, no actions, no permissions, no\nbehaviour changes. Web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-editor-layout-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"e030f641-9773-4acb-ad7e-0124de16b831","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-editor-polish-round-3","type":"changed","scope":"web","summary":"Page-builder polish round 3 — Phosphor undo/redo icons, sticky unsaved-changes banner, hover-card revisions, polished back link.","body":"Continuation of the layout-fix + polish-round-1 work for the\npage builder editor at `/saas/website/$id`. Four more small\ncraft improvements operators will notice every session.\n\n### 1. Undo / redo buttons use proper icons\n\nThe undo / redo buttons in the page-header action row rendered\nunicode glyphs `↶` and `↷`. These look at random sizes /\nweights depending on the operator's system font; on some\ncombinations they're barely visible against the ghost-button\nbackground. Replaced with Phosphor `ArrowCounterClockwise` and\n`ArrowClockwise` at `size={14} weight=\"bold\"` — visually\nmatches the rest of the icon toolbar (View live, Show\npreview, kebab).\n\n### 2. Unsaved-changes banner is now sticky\n\nThe Phase 17.B.1 unsaved-changes banner (\"Unsaved changes —\npress Cmd/Ctrl+S to save\") was a regular Card that scrolled\nout of view as operators worked their way through the long\nform (title + meta + 14-block editor + revision history). On\na long page, the operator could be 5 screens deep and have no\nvisible affordance to save without scrolling all the way\nback up.\n\nNow `sticky top-2 z-10` with `backdrop-blur-sm` + a soft\nwarning tint so it stays visible at the top of the scroll\ncontainer at every position. The save affordance is now one\nclick away from every scroll position.\n\n### 3. Revision-history rows hover as cards\n\nThe revision-history list used a `border-b pb-3` divider\nbetween items — read as a list of bare entries with no\ninteractive cue, even though the Diff + Restore buttons inside\neach row are interactive. Wrapped each row in `rounded-md\npx-2 py-2 hover:bg-muted/30 transition-colors`, dropped the\nbottom-border separator, and tightened the rhythm from\n`space-y-3` to `space-y-1`. Reads as a list of cards instead\nof a wall of text.\n\n### 4. \"← All pages\" back link looks like a back affordance\n\nThe breadcrumb-style back link `← All pages` was a plain\ninline text link rendering a unicode arrow at default text\nsize. Upgraded to a proper back-affordance:\n\n- Phosphor `CaretLeft` icon at the same weight as the rest of\n  the chrome\n- Hover state — `hover:bg-muted/40 hover:text-fg`\n- Tighter spacing (`-mx-1.5 -my-1` so the hit area extends\n  slightly beyond the visible text without changing the\n  layout)\n- Matches the back-affordance pattern used on other `/saas/*`\n  detail pages\n\nPure visual polish; no schema, no actions, no permissions, no\nbehaviour changes. Web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-editor-polish-round-3.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"6f5af982-2f7a-45ff-a144-f2c9446c7b72","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-editor-polish-round","type":"changed","scope":"web","summary":"Page-builder visual polish — status badges now color-coded with dots, empty state has a real CTA, cards hover, action toolbar reads as grouped clusters.","body":"Follow-up to the layout-correctness commit. Four small UI\nimprovements operators will notice on every editing session:\n\n### 1. Status badges are color-coded + have a leading dot\n\nStatus pills (`draft` / `pending review` / `published` /\n`archived`) all used `variant: 'soft'` or `'outline'` with no\n`tone` — pending_review and archived rendered identically as\nmuted neutral pills. Color-only signal failures used to be\ncommon a11y issues + made the page list hard to scan at a glance.\n\nNew mapping:\n- `draft` — outline + neutral (the default state)\n- `pending review` — soft + warning (yellow-amber — needs an\n  approver's attention)\n- `published` — solid + success (green — live to visitors)\n- `archived` — soft + neutral (the same muted treatment, but\n  distinguishable from pending now)\n\nAll four also get the Badge primitive's `dot` modifier — a\nsmall leading status circle in the tone color, so even\noperators on the lowest-contrast theme variant get a shape\nsignal beyond color.\n\nApplied at both the page header (h1 sibling) and inside every\nrevision-history row.\n\n### 2. Section cards now hover\n\nThe card div was clickable (expand on click, drag handle for\nreorder, Tab to focus) but had no `:hover` state — operators\nhad to learn by accident that the row was interactive. Added\n`hover:border-primary/30` so the border lifts to the primary\ntone on cursor-over. Pairs with the existing focus-visible\nring for keyboard users.\n\n### 3. Empty state actually invites action\n\nWhen `sections.length === 0`, the dashed-border placeholder\nread: \"No sections yet. Click 'Add section' below to start.\"\nTwo scan steps + a cursor trip down to find the button.\n\nReplaced with a Helios-standard empty state:\n- Phosphor `Stack` duotone icon in a primary-tinted circle\n- Headline: \"Start with a section\"\n- Helper line: \"Pick from N block types — hero, prose, feature\n  grid, FAQ, and more.\"\n- Primary CTA: \"Add your first section\" that opens the slash\n  menu directly (no scroll, no second click)\n- Plus the existing `/` shortcut hint next to the button\n\n### 4. Per-card action toolbar reads as 4 clusters, not 8 buttons\n\nThe 7-8 icon buttons in each card's right-edge action row\n(Move up / Move down / Note / Insert / Change type / Duplicate\n/ Save-as-global / Delete) had no visual grouping — the eye\nread them as one undifferentiated stack and operators had to\nread every tooltip to find the right one.\n\nAdded 1px vertical hairline separators between logical\nclusters:\n\n`[Move up · Move down]` ┊ `[Note]` ┊ `[Insert · Change type · Duplicate · Save-as-global]` ┊ `[Delete]`\n\nPlus the Delete button now lights up `text-destructive` on\nhover (instead of looking identical to the other 7 ghost\nbuttons) so accidental clicks are less likely + a deliberate\nclick confirms intent.\n\nPure visual polish; no schema, no actions, no permissions, no\nbehaviour changes. Web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-editor-polish-round.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"5ad5279d-4d8d-44b6-a9ce-900cb0050534","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-hreflang","type":"added","scope":"website","summary":"Phase 17.E.3 — `website.page.get_public` now returns `availableLocales`; marketing renderer emits `<link rel=\"alternate\" hreflang>` only for translations that actually exist.","body":"The static `hreflangAlternates()` helper in\n`apps/marketing/src/lib/i18n.ts` was deliberately optimistic —\nit emitted `<link rel=\"alternate\" hreflang>` for every locale\nthe site supports, even when a CMS page hadn't been translated\ninto most of them. Search engines followed those links to URLs\nthat fell back to the English row, which Google's i18n docs\nflag as a soft-duplicate signal.\n\nThis commit threads the actual translation set from the CMS\nthrough to the renderer.\n\n**Action change** — `website.page.get_public` now returns\n`{ page, availableLocales: string[] }`. A single SELECT pulls\nevery locale of the (kind, slug) tuple (cheaper than the prior\n\"try requested locale, then fall back to en\" two-query\npattern); the handler picks the best-fit row in-memory and\nreturns the locale list sorted alphabetically. A new\n`PagePublicOutput` schema keeps admin `getPage` (which doesn't\nneed hreflang) untouched.\n\n**Marketing layer** — `CmsPageFull` gains an optional\n`availableLocales?: string[]`; `getPage()` in\n`cms-runtime.ts` threads it onto the returned page object.\n\n**Layout** — `BaseLayout.astro` accepts a new\n`availableLocales?: readonly string[]` prop. When supplied,\nthe static helper's full locale list is filtered to keep only\nthe supplied locales (plus the `x-default` row, which always\npoints at the canonical URL). `PageLayout.astro` forwards the\nprop; the canonical CMS page template `product/[slug].astro`\npasses `cmsPage.availableLocales` through.\n\nBackwards-compatible: static MDX pages + snapshot-fallback\nrows that don't carry `availableLocales` still get the full\noptimistic alternates set. Bundled snapshot type left\n`availableLocales` optional so old data still loads.\n\n+1 test (the new \"exposes every published locale\" assertion),\n1 updated test (the locale-fallback test re-shaped for the\nsingle-query handler). 303 / 20 website tests green; marketing\ntypecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-hreflang.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a33fde93-91ee-4404-8148-f9c4cb1a58b6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-jsonld-and-robots","type":"added","scope":"marketing","summary":"Phase 17.E.4/5 — Review/HowTo/ItemList JSON-LD auto-emit from testimonial/diagram_flow/feature_grid sections + extended `<meta name=\"robots\">` (nofollow/max-snippet/max-image-preview).","body":"The earlier 17.E.4 commit covered FAQPage + Article. This\nfinishes the per-section JSON-LD pass with the three remaining\nhigh-value schema.org types Google + Bing reward in the SERP:\n\n- **Review** — emitted per `testimonial` section. `reviewBody`\n  = the quote; `author` = the attribution (when supplied);\n  `itemReviewed` = the operator's organization (resolved from\n  branding.companyName / appName).\n- **HowTo** — emitted per `diagram_flow` section. Each step\n  becomes an `HowToStep` with a 1-based `position`; the section\n  caption (or page title fallback) becomes the HowTo `name`.\n- **ItemList** — emitted per `feature_grid`. Each tile title\n  becomes a `ListItem` with a 1-based `position`. `name`\n  defaults to \"Features\".\n\nEmission is in one pass over the page sections (cheap on long\npages). Empty-content sections are skipped defensively\n(testimonial with blank quote, diagram_flow with no labelled\nsteps, feature_grid with all-blank tiles).\n\n### Extended robots directives (17.E.4)\n\nThe legacy `noindex` column on `website_pages` is the historic\nboolean. CMS pages now also accept three optional directives\nvia `meta.robots`:\n\n- `nofollow` (boolean) — crawlers don't follow outbound links\n- `maxSnippet` (-1 = unlimited; otherwise a character cap)\n- `maxImagePreview` (`none` / `standard` / `large`)\n\nComposed into one `<meta name=\"robots\" content=\"…\">` string\nalongside the legacy `noindex` in `BaseLayout.astro`. Emits\nonly when at least one directive is set; otherwise crawler\ndefaults apply. `PageLayout.astro` forwards a new\n`robotsExtra` prop; `apps/marketing/src/pages/product/[slug].astro`\nthreads `cmsPage.meta?.robots` through as the canonical\nexample.\n\n### Tests + checks\n\n+5 tests on `cms-page-helper.test.ts` (Review one-per /\nskip-empty-quote / HowTo with numbered steps / ItemList from\nfeature_grid / multi-schema in one pass). 18 / 1 total. Web +\nmarketing typecheck clean; website typecheck clean.\n\nRemaining 17.E items (CDN purge `/api/cms-purge` endpoint;\nper-kind sitemap shards) stay deferred — the webhooks fabric\nshipped in 17.E.6 already gives operators a BYO-purge path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-jsonld-and-robots.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"a098010b-66b9-4bb3-82b5-84b05954f0d4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-jsonld-autoemit","type":"added","scope":"marketing","summary":"Phase 17.E.4 — auto-derive `FAQPage` JSON-LD from CMS faq/accordion sections + `Article` JSON-LD for `kind=blog` pages.","body":"CMS pages always shipped page-specific schemas wired by each\n.astro template (Product / pricing / breadcrumbs). What was\nmissing: the SECTION content itself never produced schema.org\nmarkers, so a CMS page with a 12-question FAQ block didn't\nemit `FAQPage` and Google never showed it as a rich result.\n\nThis commit adds an automatic derivation pass.\n\n**`deriveCmsJsonLd(cmsPage, { canonical, branding })`** in\n`apps/marketing/src/lib/cms-page-helper.ts` walks a CMS page's\nsection payload once and returns schema.org JSON-LD nodes that\nNATURALLY follow from the content:\n\n- **`FAQPage`** when the page carries one or more `faq` and/or\n  `accordion` sections. Both shapes are folded into a single\n  `mainEntity` array of `Question` entries. Items with empty\n  question or answer are skipped (some old CMS rows have\n  drafts in them).\n- **`Article`** when `cmsPage.kind === 'blog'`. Includes\n  `headline`, `description`, `url`, `datePublished` (from\n  `publishedAt`), `dateModified` (from `updatedAt`), `image`\n  (from `ogImage` when present), and a `publisher` block\n  populated from `branding.companyName` + `marketingUrl` +\n  `logoUrl`.\n\nPages compose the result with their own schemas via the\nexisting `graph()` helper from `@/lib/schema`. Backwards-\ncompatible — pages that don't call `deriveCmsJsonLd` see no\nchange.\n\nWired into `apps/marketing/src/pages/product/[slug].astro` (the\ncanonical CMS-driven page template) as a reference; future CMS-\ndriven templates land the same one-liner.\n\nPure function (no I/O); 7 new tests cover empty / FAQ /\naccordion / fold-together / item-skip / Article shape / both-\ntogether cases. 13 / 1 cms-page-helper.test.ts file green;\nmarketing typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-jsonld-autoemit.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"87ce07a3-ed9d-4bc2-b6e0-03c1565169b4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-matrix-translate","type":"changed","scope":"web","summary":"Phase 17.B.7 — empty cells in the translation matrix become one-click \"Translate →\" buttons.","body":"`/saas/website/translations` is a pivot of every CMS page by\n`(kind, slug)` × language. Empty cells (rows that aren't\ntranslated into a given locale yet) previously rendered as a\n`—` placeholder; operators had to navigate to the source row's\neditor and pick \"Translate to…\" from a dropdown.\n\nEmpty cells now render a `Translate →` button that fires\n`website.page.translate` inline:\n\n- Source row picked from `g.byLang.get('en')` first, falling\n  back to whichever locale exists for that group (mirrors the\n  public renderer's `getPagePublic` fallback).\n- Target locale = the column the empty cell is in.\n- On success: toast + matrix refetch so the new draft replaces\n  the button.\n- On error: toast with the `ActionCallError` message.\n- Button hidden + `—` falls through when the actor lacks\n  `platform:website:page:create`.\n\nPure UI; no schema or action changes. Web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-matrix-translate.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"7fc87f92-84c9-4e64-ad54-1eb57333b55a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-multi-reviewer-ui","type":"changed","scope":"website","summary":"Phase 17.G.2.b — multi-reviewer admin UI. Page editor shows running tally + dynamic Approve button label + dedup-aware confirm dialog.","body":"Phase 17.G.2 added the action-layer fabric for multi-reviewer\napproval — schema + vote-then-publish logic on\n`website.page.publish`. Operators could wire it via raw\n`meta.requiredApprovals = N` edits today, but the page editor\ngave no visible cue that the row was using the multi-reviewer\nflow.\n\nThis commit lands the editor surface so reviewers see what's\ngoing on without having to read the action's documentation.\n\n**Pending-review banner** now branches:\n- `meta.requiredApprovals === 1` (legacy): unchanged copy.\n- `> 1`: surfaces \"N / M approvals so far\" inline + a Badge\n  (warning until threshold, success once met). When the\n  current viewer has already voted, a secondary line confirms\n  it (\"Your vote is already recorded — waiting for X more\").\n\n**Approve button label** is now:\n- `Approve & publish` — legacy single-approver case.\n- `Cast approval (N / M)` — multi-reviewer, vote not yet\n  threshold-meeting.\n- `Approve & publish (final vote)` — multi-reviewer, THIS\n  click carries the threshold over.\n- `You already voted (N / M)` — the current actor's vote is\n  already in `meta.approvals`; button DISABLED to prevent\n  double-click confusion (the action dedupes anyway, but the\n  disabled state is the right operator cue).\n\n**Confirm dialog** title + description + confirm-label adopt\nthe same vote-vs-publish distinction so a reviewer who DOES\nclick \"Cast approval\" sees a precise \"Records your vote; X\nmore reviewers needed\" instead of a misleading \"publishes\nimmediately\" copy.\n\n**Mutation handler** surfaces the multi-reviewer state from\nthe action's response in its success toast:\n- `Approval recorded (N / M). Waiting for more reviewers.` for\n  the threshold-not-met case.\n- `Published.` for the legacy + threshold-met cases.\n\nPure UI; no schema, no action, no test changes — the action-\nlayer tests from 17.G.2 cover the contract this surface\nexposes. Web typecheck clean.\n\nCloses the operator-facing loop on the multi-reviewer feature.\nThe remaining 17.G.3 page-level ACLs work stays open.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-multi-reviewer-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"12ea8548-e666-4fae-a652-2b88b00f41c1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-multi-reviewer","type":"added","scope":"website","summary":"Phase 17.G.2 — multi-reviewer approval gate for CMS publishing. `meta.requiredApprovals: N` makes `website.page.publish` a vote-then-publish action.","body":"CMS publishing has always been single-approver: anyone with\n`platform:website:page:approve` could push a pending-review row\nstraight to published. High-stakes pages (legal copy, pricing,\nhomepage) frequently want two-person approval before the change\ngoes live, but the platform didn't model it.\n\nThis commit lands a backwards-compatible multi-reviewer gate.\n\n**Schema (no migration needed — JSONB)** —\n`modules/website/src/schemas/sections.ts` `PageMetaBase` gains\ntwo optional fields:\n\n- `requiredApprovals: number` (1-10, default 1) — the threshold.\n- `approvals: string[]` (uuid) — actor ids who've voted on the\n  current `pending_review` round. Managed by the publish\n  action; operators can read it but updates to the field via\n  `website.page.update` are not blocked (the publish handler is\n  the source of truth and overwrites on every transition).\n\n**`website.page.publish` (rewired)** — when status was\n`pending_review` and `requiredApprovals > 1`, the action treats\nitself as a \"vote approval\":\n\n1. Dedupe-add the caller to `meta.approvals` (double-clicking\n   Approve doesn't double-count).\n2. If `approvals.length < requiredApprovals`: persist the vote\n   ONLY (no published transition, no events). Return\n   `{ id, published: false, approvals, requiredApprovals }`.\n3. If the threshold is met (this caller carries it over):\n   proceed with the existing publish path. The completed round's\n   approvals are cleared from meta as part of the published\n   write so the next `request_review` starts with zero votes.\n\nLegacy single-approver flow (`requiredApprovals` unset or 1) is\nidentical to before — the caller's vote-of-one is recorded,\nreturned, and the publish proceeds in the same handler call.\n\n**`request_review` + `reject_review`** also clear\n`meta.approvals` on transition so a re-submission starts from\nzero rather than inheriting stale votes from the prior round.\n\n**Output schema** — new `PublishPageOutput` extends `PageIdOutput`\nwith `{ published, approvals?, requiredApprovals? }`. Callers\nthat only used `result.id` are unaffected.\n\n**Tests** — 4 new tests cover:\n\n- vote recorded, threshold not met, no publish, single UPDATE\n- threshold met, publish proceeds, event fires\n- double-vote dedup\n- legacy single-approver flow returns the new shape unchanged\n\n312 / 21 website tests green. Website typecheck clean.\n\nAdmin UI surface (running tally + per-reviewer Approve button)\nis deferred to a 17.G.2.b follow-up — operators can wire the\nfield via raw `meta` edits today.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-multi-reviewer.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"19b1fc23-b812-43b1-84ed-0d284980c4eb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-orphan-revision-gc","type":"added","scope":"website","summary":"Daily GC of `website_page_revisions` — keep newest 50 per page + last 90 days, trim the rest.","body":"Closes Phase 17.G.5 — the last open audit-and-housekeeping item\nof the Website CMS v3 plan.\n\n### Why\n\n`website_page_revisions` is append-only — every `website.page.update`\nsnapshots the *previous* sections / meta / tags / status. Operators\nuse the log to compare versions, diff against the current draft, or\nrestore a prior one. Useful. Also unbounded — a heavily-edited\npage (a hero where ten authors fine-tune copy daily for six\nmonths) reaches 1000+ rows. The diff dialog only goes back ~10 by\ndefault, the listRevisions surface caps at 50.\n\n### What ships\n\n- **`runOrphanRevisionGcSweep`** (`modules/website/src/jobs/orphan-revision-gc.ts`):\n  Per-page sweep keeping (newest N) ∪ (last M days). Defaults to\n  N=50 + M=90 so the listRevisions surface continues to render its\n  full window and the diff dialog continues to cover the recent\n  history.\n  - Rule 1 (rank): keep the newest N regardless of age. A page\n    edited once a year keeps its 50 most recent.\n  - Rule 2 (age): keep everything within M days regardless of\n    count. A page edited daily keeps the full 90-day window.\n  - Union: a page edited 100 times in the last 90 days keeps all\n    100 (rule 2 wins). A page edited 10 times over 5 years keeps\n    all 10 (rule 1 wins).\n  - Cheap on the common path — pages whose revision count is\n    under the keep threshold produce zero deletes.\n  - Chunked DELETEs (500 per batch) to keep the IN list sane on\n    Postgres.\n\n- **`website.audit.gc_orphan_revisions`** action: manual-fire\n  variant scoped to the calling org. Optional `pageId` to scope to\n  a single page. Optional `keepLatestPerPage` / `keepDays`\n  overrides for ad-hoc surgery. Gated on\n  `platform:website:page:archive` since it removes historical\n  state from the same surface the archive + restore flow uses.\n\n- **`startWebsiteOrphanRevisionGcCron`** (`apps/worker/src/website-orphan-revision-gc-cron.ts`):\n  Daily tick, 40-min stagger post-boot to avoid racing the other\n  four website crons (scheduled-publish 60s tight / media-scrub\n  +10 min / tag-usage-reconcile +20 min / audit +30 min) for the\n  DB pool.\n\n- **15 unit tests** in `orphan-revision-gc.test.ts` covering\n  zero-revision fast path, under-limit no-op, both retention\n  rules in isolation, union behaviour, per-page independence,\n  custom limits, `keepDays=0` (rule-2-disabled) escape hatch,\n  large-input chunking, single-org/single-page scoping, and\n  multi-page deletion accounting.\n\n### Footprint\n\nNet new: 1 sweep file + 1 cron file + 1 action + 1 test file.\nModule suite now 367 tests across 25 files (up from 352 across\n24). No schema migration — the table already has the\n`(page_id, snapshot_at)` index the sweep depends on.\n\n### Operations\n\nDefault retention is generous (50 newest + 90 days). On a typical\ndeployment with ~100 marketing pages and ~5 edits per page per\nmonth, the first tick reports `expired: 0` and stays that way.\nDrift only appears after a bulk-edit pass (e.g. an i18n sweep that\ntouches every page on the same day for a quarter); then the\nbacklog clears in one chunked sweep and the daily tick returns to\nzero-write steady state.\n\nOperators who want to reclaim space immediately after a known\nbulk-edit can call `website.audit.gc_orphan_revisions` from the\nadmin surface — same sweep, no wait for the cron.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-orphan-revision-gc.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"9d0dab79-aa66-4d2f-bfde-024edc4dae64","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-tag-usage-cron","type":"added","scope":"website","summary":"Phase 17.G.4 — daily reconciliation cron for `website_blog_tags.usage_count` + manual-fire admin action.","body":"`website_blog_tags.usage_count` is the denormalized popularity\ncounter the blog admin's tag list sorts on and the \"deletable\nwhen usage = 0\" guard reads. The action-layer\n`applyTagUsageDelta` hook keeps it correct on the happy path\n(every create / update / archive / restore of a blog page),\nbut drift can creep in via:\n\n- Pre-Phase 16.E migrations that backfilled tag refs without\n  touching the count\n- Bulk DB imports that bypassed the action layer\n- Raw `UPDATE` ops surgery\n- Any future write-path regression\n\nThis commit adds a reconciliation sweep that walks every org's\nblog pages, recomputes each tag's actual count from ground\ntruth, and writes back only the rows whose stored count\ndiffers.\n\n**Sweep** — `modules/website/src/jobs/tag-usage-reconcile.ts`\nexposes `runTagUsageReconcileSweep(db, { orgId? })` returning\n`{ scannedOrgs, scannedTags, drifted, updated }`. Cheap on the\ncommon path (zero drift → zero writes).\n\n**Action** — `website.audit.reconcile_tag_usage` (write,\n`blog:manage`-gated). Org-scoped manual-fire variant. Operators\nhit it after a known surgery; idempotent.\n\n**Cron** — `apps/worker/src/website-tag-usage-reconcile-cron.ts`\nruns the sweep daily across every org, stagger-offset 20 min\npost-boot so it doesn't race the media-scrub cron's 10-min\noffset or the scheduled-publish 60s loop for the DB pool.\n\n**Tests** — 5 new sweep tests covering: zero-drift fast path,\ndrift detection + correct count write, clamp-to-0 for rotted\ntags, multi-org walk, and the no-tag-rows early return.\n\n308 / 21 website tests green; website typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-06-04-website-tag-usage-cron.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"ae9def14-3389-4803-93ec-31c45c85d9da","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"auth-extra-trusted-origins","type":"added","scope":"auth","summary":"Added BETTER_AUTH_EXTRA_TRUSTED_ORIGINS env var so additional hostnames pass the CSRF/Origin check during host migrations.","body":"When the deployment is mid-migration to a new hostname — or running a\nshort-lived parallel hostname for testing — Better-Auth's origin check\nwould previously reject any request whose `Origin:` header didn't match\n`BETTER_AUTH_URL` exactly. Flipping `BETTER_AUTH_URL` worked but forced\na full cutover.\n\n`BETTER_AUTH_EXTRA_TRUSTED_ORIGINS` accepts a comma-separated list of\nfull origins (protocol + host) that are accepted alongside the base\n`BETTER_AUTH_URL`. Each entry is normalised the same way as the base\nURL (trim, strip trailing slash, add bare-origin form), so:\n\n```\nBETTER_AUTH_EXTRA_TRUSTED_ORIGINS=https://app.new-domain.com,https://staging.app.new-domain.com\n```\n\n…lights up both new hostnames for CSRF/Origin matching without touching\n`BETTER_AUTH_URL` (passkey rpID, OAuth callback URLs, etc. continue to\nflow from `BETTER_AUTH_URL`). When the cutover is final and the old\nhostname is gone, drop the env var.\n\nNo-op when unset.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/auth-extra-trusted-origins.md","internalOnly":false,"createdAt":"2026-06-04T01:29:41.695Z","updatedAt":"2026-06-04T01:29:41.695Z"},{"id":"07b8ac72-5ee1-4400-a851-4e47eccf561c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-47-ai-bloom-onanimationend","type":"fixed","scope":"chat","summary":"Round 47 — Ask Helios Sparkle bloom→ambient-breath handoff switched from a fragile `setTimeout(720ms)` to `onAnimationEnd`. Slow first-render no longer cuts the bloom off mid-scale.","body":"Surfaced by audit:thread-huddle Gap #2.\n\nThe Ask Helios side pane's Sparkle icon does a one-shot bloom on first mount (the user clicked \"Ask Helios\" and the pane just opened), then transitions to an infinite ambient breath. The bloom→breath handoff was timed via `setTimeout(() => setBloomDone(true), 720)` from the `useEffect` mount.\n\n**Bug:** the timer fires after 720 ms **wall-clock from useEffect run**, but the CSS keyframe only STARTS once the element is painted. On a slow first render of a heavy pane (cold app boot, low-power device, route transition jank), paint can slip 100-300 ms after the React effect fires. The timer was racing the animation's start, not its end — and when paint took > 220 ms, the bloom got cut off mid-scale because the timer expired before the keyframe finished.\n\n**Fix:**\n\n```diff\n  const [bloomDone, setBloomDone] = useState(false);\n- useEffect(() => {\n-   const t = setTimeout(() => setBloomDone(true), 720);\n-   return () => clearTimeout(t);\n- }, []);\n\n  …\n\n  <Sparkle\n    size={14}\n    weight=\"fill\"\n    className={bloomDone ? 'helios-chat-ai-sparkle' : 'helios-chat-ai-bloom'}\n+   onAnimationEnd={() => {\n+     if (!bloomDone) setBloomDone(true);\n+   }}\n  />\n```\n\n`onAnimationEnd` fires the moment the CSS keyframe actually completes — regardless of when render or paint started. The only animation on this element while `!bloomDone` is `helios-chat-ai-bloom`; the first animationend event after mount IS the bloom finishing. Once `bloomDone` flips, the class becomes `helios-chat-ai-sparkle` (an infinite breath that never ends), so the handler is never re-entered.\n\nAlso drops the React effect + cleanup pair, net -3 LOC.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.\n\n**Source:** audit:thread-huddle Gap #2 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-04T01:37:05.979Z","updatedAt":"2026-06-04T01:37:05.979Z"},{"id":"3296f823-0698-4f2a-adc8-36e039b1a06f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"account-portal-datatable","type":"changed","scope":"clients","summary":"Client-portal Invoices, Quotations, Documents, and Team lists now use the unified DataTable.","body":"The client portal's record lists moved from hand-rolled `<ul>` lists to the shared\nDataTable, gaining sortable columns, a search box, and consistent styling while\nkeeping the portal's lightweight feel (density and column-visibility toggles are\nhidden):\n\n- **Invoices** & **Quotations**: keep their keyset \"Load more\" paging (the table's\n  own pager is disabled) and the per-row Pay / Accept-&-sign / Decline / Pay-now\n  actions; the area summary and accept-signature dialog are unchanged.\n- **Documents**: name (with \"You uploaded\" tag), type, size, expiry, and the\n  Download action; the upload button is unchanged.\n- **Team**: name (with Primary / You tags), email, access status, and the\n  Invite / Revoke actions.\n\nThe Statement page is left as-is — it's a printable financial statement, not a\nworking datagrid. Part of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.291Z","updatedAt":"2026-06-05T01:29:10.291Z"},{"id":"4b89442e-e11e-41c6-b28e-0b2dba8bd555","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-conflict-guard-seo-override-slug-collision-actions","type":"changed","scope":"website","summary":"Phase 14.A — extends conflict guard to all status-flip mutations, marketing renderer now consumes seoOverride, adds slug-collision list/resolve actions.","body":"A fresh gap audit of the 12-phase gap-closure plan surfaced three\nbroken-promise bugs — shipped backend that did nothing\nuser-visible. Phase 14.A closes them.\n\n**D — `expectedUpdatedAt` extended to status-flip verbs.** Phase 7\nadded the optimistic-concurrency guard to `website.page.update`\nonly. `publish`, `archive`, `schedule_publish`, `request_review`,\nand `reject_review` were still last-write-wins; an approver could\npublish over an in-flight edit without seeing the conflict.\n\nToday all five accept the same optional `expectedUpdatedAt: string`\n(ISO 8601). When supplied, the handler compares to the row's\ncurrent `updated_at`; mismatch returns `conflict` with the actual\ntimestamp in the message. Omit to keep last-write-wins (CLI / AI\ntool / cron callers). A shared `checkExpectedUpdatedAt()` helper\nin `modules/website/src/actions/page.ts` centralises the logic.\n\n**A — Marketing renderer consumes `section.seoOverride`.** Phase 6\nadded `seoOverride?: { title?, ogImage? }` to hero / cta_footer /\nfeature_grid section schemas; nothing read it. Operators set the\nfield and saw no override in the rendered `<head>`.\n\n`apps/marketing/src/lib/cms-page-helper.ts` now scans the page's\nsections top-down and lets the FIRST `seoOverride` win. Precedence:\nsection override → CMS row title/ogImage → fallback. The hero's\nlaunch image now actually wins over a stale row-level og_image.\n\n**Slug-collision admin actions** — Phase 9 populated\n`website_slug_collisions` but exposed no list / resolve actions.\nOperators were blind to collisions unless they grepped logs.\n\nTwo new actions in `modules/website/src/actions/slug-collisions.ts`:\n\n- `website.slug_collision.list({ includeResolved?, limit? })` —\n  leftJoins the current page row so each entry surfaces with its\n  title. Defaults to unresolved-only.\n- `website.slug_collision.resolve({ id })` — stamps `resolved_at`\n  + `resolved_by`. Idempotent (returns existing stamp). Audit row\n  stays for the trail.\n\nBoth gated by `:read` / `:update` (no new permission key —\ncollisions are a sub-surface of the page admin).\n\n**13 new tests** — 5 for slug-collision actions (list happy +\ndenial; resolve happy + idempotent + not-found + denial), 7 for\nthe conflict-guard extension (stale + matching across publish /\narchive / schedule / request / reject + backwards-compat). 167/167\nmodule tests pass.\n\nUI follow-ups (Phase 14.B/C/D/E/F) queued separately: slug-collision\nadmin route, allowedBlockTypes chip control, approval queue,\nlocale completeness, scheduled-queue aggregated view.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.316Z","updatedAt":"2026-06-05T01:29:10.316Z"},{"id":"b9b5cdcf-fe0f-4602-b8bc-5919df3863b4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-38-typography-polish","type":"changed","scope":"chat","summary":"Round 38 — `text-wrap: pretty` on message bodies (no more orphan \"ok.\" on line 4); entity-chip hover gets a 150 ms transition (no more snap); AI-composing inline label moves to `--text-chat-meta` token.","body":"Three small, additive typography wins from the typography-density research lens + audit:thread-huddle and audit:styles-css.\n\n### 1. `text-wrap: pretty` on message bodies\n\n[CSS Text Module Level 4](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-wrap), Baseline 2024. Single-line declaration, no JS, browser optimises line-breaks across the last 4-6 lines of a paragraph — fixes the lone orphan word (`\"ok.\"`) dangling on line 4 of an otherwise full paragraph. Applied to both the TipTap-rendered `.helios-message-prose` AND the plain-text fallback path in `message-body.tsx` for legacy messages without a doc.\n\nWe deliberately did NOT use `text-wrap: balance` here: Chrome caps balance at 6 lines, Firefox at 10, and long messages would silently revert to default wrap mid-message. `pretty` is the right call for variable-length bodies; `balance` is reserved for titles and 1-3 line dividers.\n\n```css\n.helios-message-prose {\n  /* ... existing rules ... */\n  text-wrap: pretty;   /* progressive enhancement — falls back to wrap */\n}\n```\n\n### 2. Entity-chip hover snap → 150 ms transition\n\nThe entity-chip (`<a class=\"helios-entity-chip\">` — used for cross-module deal/lead/contact/task chips inside messages) declared `:hover` rules that change `transform`, `filter`, and `box-shadow` together, but had NO `transition` defined on the base rule. State changes snapped instantly instead of flowing, breaking parity with the rest of the chat motion language.\n\n```css\na.helios-entity-chip {\n  /* ... existing ... */\n  transition:\n    transform  150ms cubic-bezier(0.16, 1, 0.3, 1),\n    filter     150ms ease,\n    box-shadow 150ms ease;\n}\n```\n\n### 3. AI-composing typing-label uses `--text-chat-meta`\n\nThe \"{AppName} AI is composing a reply…\" subtitle inside the typing indicator was hardcoded to `fontSize: '11.5px'`. The token `--text-chat-meta` resolves to `clamp(11.5px, 0.72rem + 0.06vw, 12.5px)`, so on ultrawide displays the rest of the chat scale crept up while the AI subtitle stayed flat. Now it scales with the rest of the chat type system.\n\n```diff\n- <span className=\"font-semibold tracking-tight\" style={{ fontSize: '11.5px' }}>\n+ <span className=\"font-semibold tracking-tight\" style={{ fontSize: 'var(--text-chat-meta, 12px)' }}>\n```\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:**\n- [LogRocket — When to use CSS text-wrap: balance vs. pretty](https://blog.logrocket.com/css-text-wrap-balance-vs-text-wrap-pretty/)\n- [MDN — text-wrap](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-wrap)\n- chat-CSS audit Gap #6 (entity-chip transition) + audit:thread-huddle Gap #9 (AI pill token)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.546Z","updatedAt":"2026-06-05T01:29:10.546Z"},{"id":"88cc47a4-a8f4-4b2f-b59e-fb7973e6bf70","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-41-motion-vocabulary-tokens","type":"changed","scope":"chat","summary":"Round 41 — chat motion vocabulary consolidated into 9 design tokens (3 easing curves + 6 durations) at the `[data-helios-chat]` scope. Four highest-traffic animations migrated as proof of use; remaining keyframes can adopt incrementally without breaking anything.","body":"Surfaced by the chat-CSS audit (Critical Gap #1 + Gap #2): chat animations used three different `cubic-bezier()` curves and ~12 different durations with no shared definition. Hard to evolve, drift-prone, no semantic distinction between adjacent values (220 ms vs. 200 ms vs. 280 ms had no story).\n\n### Tokens added\n\nAt the `[data-helios-chat], [data-helios-chat-popover]` root scope (same place the type scale + density tokens live):\n\n```css\n--ease-standard:    cubic-bezier(0.16, 1, 0.3, 1);    /* gentle ease-out */\n--ease-spring-soft: cubic-bezier(0.34, 1.32, 0.64, 1); /* subtle overshoot */\n--ease-spring:      cubic-bezier(0.34, 1.56, 0.64, 1); /* strong overshoot */\n\n--duration-instant: 80ms;   /* reduced-motion fallback + \"did it happen?\" feedback */\n--duration-snap:    160ms;  /* popover / menu open + close */\n--duration-quick:   200ms;  /* hover transitions, focus rings */\n--duration-lazy:    280ms;  /* sticky-pill fade, reaction cascade per emoji */\n--duration-modal:   320ms;  /* spring entrance + optimistic row land */\n--duration-bloom:   720ms;  /* one-shot AI bloom / pulses / thread mount */\n```\n\nEach named curve has a clear job:\n\n- **`--ease-standard`** is the everyday ease-out — hover, popover fade, scroll-driven opacity. Used for ~80% of chat motion.\n- **`--ease-spring-soft`** is the subtle overshoot — action-bar toolbar entrance, anything that should feel \"alive but not bouncy.\"\n- **`--ease-spring`** is the strong overshoot — \"commit\" moments (reaction add, send confirm, modal entrance) where the motion is part of the user feedback.\n\nEach duration is on a perceptual ladder — 80 / 160 / 200 / 280 / 320 / 720 ms — not arbitrary round numbers. They map to research-derived bands of human perception: < 100 ms reads as \"instant\", 100-300 ms as \"fast feedback\", 300-500 ms as \"comfortable motion\", 500 ms+ as \"intentional reveal\".\n\n### Migration: 4 highest-traffic animations adopt the tokens\n\nTo prove out the use pattern + lock in the values, the four most-visible chat animations are migrated:\n\n| Class | Was | Now |\n|---|---|---|\n| `.helios-chat-spring-in` | `320ms cubic-bezier(0.34, 1.56, 0.64, 1)` | `var(--duration-modal) var(--ease-spring)` |\n| `.helios-chat-popover-in` | `160ms cubic-bezier(0.16, 1, 0.3, 1)` | `var(--duration-snap) var(--ease-standard)` |\n| `.helios-msg-actions` | `200ms cubic-bezier(0.34, 1.32, 0.64, 1)` | `var(--duration-quick) var(--ease-spring-soft)` |\n| `.helios-chat-react-emoji` | `280ms cubic-bezier(0.34, 1.56, 0.64, 1)` | `var(--duration-lazy) var(--ease-spring)` |\n\nEach migration uses `var(--token, fallback)` so the rules still work on surfaces that don't carry the `[data-helios-chat]` root scope (rare — the AI side-panel + certain modals).\n\n### Why additive, not big-bang\n\nMigrating every keyframe in one commit would touch ~40 rules across `styles.css` and create a deep diff that hides regressions. The tokens are ADDITIVE — existing inline values keep working — and future polish rounds (or a deliberate refactor sweep) can migrate the rest one rule at a time with `git blame`-friendly history.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:**\n- chat-CSS audit Critical Gap #1 (easing-curve fragmentation) + Gap #2 (duration inconsistency)\n- Material 3 Motion: easing + duration tokens\n- Linear's March 2026 UI refresh (named motion curves)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.798Z","updatedAt":"2026-06-05T01:29:10.798Z"},{"id":"3b9785e8-4c41-4003-824a-68afbe6fff84","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-42-composer-modal-focus-restoration","type":"fixed","scope":"chat","summary":"Round 42 — composer's three modals (NewPoll, LinkWorkPicker, ImageAnnotator) now restore focus to the editor on close. Previously closing via Esc / backdrop / Cancel left focus on `document.body` and the user's next Tab landed in a random place.","body":"Surfaced by audit:composer Gap #10. Direct WCAG 2.4.3 (Focus Order) violation and one of the most-cited a11y bugs in dialog-heavy UIs.\n\nWhen the user opened any composer modal (Create Poll / Link Work picker / Image Annotator) and closed it via Esc / backdrop click / Cancel button, focus was NOT restored to the trigger. The active element became `document.body` and the next Tab keypress landed on whatever the browser's natural tab order pointed to — usually the Send button, sometimes a random surface chrome element. Keyboard journey broken; screen-reader users lost their place in the conversation.\n\n**Fix:** add `editor?.commands.focus()` to each `onClose`:\n\n```diff\n  <NewPollModal\n    open={pollOpen}\n-   onClose={() => setPollOpen(false)}\n+   onClose={() => {\n+     setPollOpen(false);\n+     editor?.commands.focus();\n+   }}\n  />\n\n  <LinkWorkPicker\n    open={linkWorkOpen}\n-   onClose={() => setLinkWorkOpen(false)}\n+   onClose={() => {\n+     setLinkWorkOpen(false);\n+     editor?.commands.focus();\n+   }}\n  />\n\n  <ImageAnnotator\n    file={file}\n-   onClose={() => setEditingAttachmentId(null)}\n+   onClose={() => {\n+     setEditingAttachmentId(null);\n+     editor?.commands.focus();\n+   }}\n  />\n```\n\nTipTap's `editor.commands.focus()` is the canonical re-focus call — it puts the caret back at the previous position, restoring not just focus but the cursor's exact in-document position.\n\n**What we did NOT change:** EmojiPicker close, smart-compose menu close. Both of those return focus naturally via Radix's built-in `onCloseAutoFocus` mechanism (verified via their existing component contracts).\n\n**Verification:** chat 107/107 tests pass; composer-tiptap.tsx typecheck clean.\n\n**Sources:**\n- audit:composer Gap #10 (workflow `wf_14d2b01a-8de`)\n- [WCAG 2.4.3 Focus Order](https://www.w3.org/WAI/WCAG21/Understanding/focus-order.html)\n- [Mastering Dialog Accessibility — Vispero](https://vispero.com/resources/mastering-dialog-accessibility/)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.929Z","updatedAt":"2026-06-05T01:29:10.929Z"},{"id":"70cc2eb8-8686-4c48-8ec9-f631cb24c2fc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-35-reduced-motion-completeness","type":"fixed","scope":"chat","summary":"Round 35 — close reduced-motion coverage gap. `.helios-message-new` (1.4 s), `.helios-message-focused` (2.4 s), `.helios-access-flash` (1.2 s) and the indeterminate `.helios-access-progress` slider now collapse / stop when `prefers-reduced-motion: reduce`.","body":"Surfaced by the chat-CSS audit (Critical Gap #3): four long-running tint-pulse + indeterminate-slider animations ran their full 1.2–2.4 s cycles regardless of the user's motion preference because they were never listed in the snap-to-fade block. The rest of the chat motion vocabulary (popovers, spring entrances, AI bloom, column fade-in, react-emoji cascade) was already wired up correctly.\n\nA 2-3 s tint-pulse re-painting in the corner of a user's eye is exactly the \"decorative attention-grab\" WCAG 2.2.2 (Pause, Stop, Hide) + 2.3.3 (Animation from Interactions) ask us to suppress for vestibular-disorder users.\n\n**Fix:**\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  /* existing block — popovers, spring-in, bloom, etc. */\n  /* … */\n\n  /* NEW — round 35 */\n  .helios-message-new,\n  .helios-message-focused,\n  .helios-access-flash {\n    animation-duration: 80ms !important;\n    animation-name: fadeIn !important;\n  }\n  .helios-access-progress {\n    animation: none !important;\n  }\n}\n```\n\nThe four pulses collapse to the same 80 ms `fadeIn` as the rest of the chat motion vocabulary; the indeterminate progress slider hard-stops (no point in collapsing it — the indeterminate semantics are visual, not stateful).\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:**\n- [WCAG 2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG21/Understanding/pause-stop-hide.html) — the canonical guidance for animation > 5 s\n- [WCAG 2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html)\n- chat-CSS audit (Round 32-34 follow-up sweep)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.967Z","updatedAt":"2026-06-05T01:29:10.967Z"},{"id":"f5bec4b0-dc40-4165-b120-4d0e7a1ff384","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-44-huddle-keyframe-consolidation","type":"changed","scope":"chat","summary":"Round 44 — `helios-pulse-glow` (incoming-call card breathe) + `helios-pulse-dot` (subtitle status pulse) moved from inline `<style>` blocks in `huddle-incoming-call.tsx` to global keyframes in `styles.css`. Reduced-motion guard added; silent 0.30 vs 0.35 opacity drift fixed.","body":"Surfaced by audit:thread-huddle Gap #3 + Gap #6.\n\n`apps/web/src/components/chat/huddle-incoming-call.tsx` was the ONLY chat component defining its own animations via inline `<style>` JSX blocks. Every other chat surface uses the shared rules in `apps/web/src/styles.css`. Two anti-patterns:\n\n1. **`helios-pulse-glow`** — defined ONLY in the inline `<style>` block. Future motion-scale or reduced-motion edits would skip it because a global grep wouldn't find it.\n\n2. **`helios-pulse-dot`** — defined BOTH globally (`styles.css:1280` at 50% → 0.35 opacity) AND inline in the component (at 50% → 0.30 opacity). The inline definition silently overrode the global, lowering minimum opacity by 5 percentage points. Silent drift bug that would only surface if someone tried to consolidate.\n\n**Fix:**\n\n1. Add `@keyframes helios-pulse-glow` to `styles.css` next to the existing `helios-pulse-dot`. Identical to the inline definition.\n2. Add both `helios-pulse-glow` and `helios-pulse-dot` consumers to the reduced-motion block via `[style*=\"helios-pulse-*\"]` selector — both are decorative infinite loops that should hard-stop for vestibular-disorder users.\n3. Remove the two inline `<style>` blocks from `huddle-incoming-call.tsx`, leaving comments pointing at the new home.\n\n**Behaviour delta:** the subtitle pulse-dot's minimum opacity goes from 0.30 → 0.35 (matches the global definition + matches what `chat-channels-sidebar.tsx` uses for the \"reconnecting\" status). Visually imperceptible; semantically correct.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** audit:thread-huddle Gaps #3 + #6 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.973Z","updatedAt":"2026-06-05T01:29:10.973Z"},{"id":"3e862c45-1164-4bf4-a77f-16fe3154b93c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-43-sidebar-polish-bundle","type":"changed","scope":"chat","summary":"Round 43 — sidebar polish bundle. Drop indicator bumps 2.5 → 3 px + gains a module-tint glow; muted channel rows get an inset 2 px left strip (a11y redundancy for opacity-only); Saved + Pulse popovers get stable bottom-fade overlays so users see content below the fold.","body":"Three small fixes from audit:sidebar, batched because they touch one related concern (sidebar surface visual signals):\n\n### 1. Drop-indicator visibility (audit:sidebar Gap #3)\n\nThe 2.5 px horizontal drop bar on dragOver was too thin to catch during fast multi-channel drag sequences, especially on bright/light backgrounds.\n\n```diff\n- className=\"ui-breath pointer-events-none absolute -top-px left-2 right-2 h-[2.5px] rounded-full\"\n- style={{ background: 'var(--color-module-chat)' }}\n+ className=\"ui-breath pointer-events-none absolute -top-px left-2 right-2 h-[3px] rounded-full\"\n+ style={{\n+   background: 'var(--color-module-chat)',\n+   boxShadow:\n+     '0 0 8px 0 color-mix(in oklch, var(--color-module-chat) 60%, transparent),\n+      0 1px 3px -1px color-mix(in oklch, var(--color-module-chat) 40%, transparent)',\n+ }}\n```\n\n3 px + soft glow + the row's existing dragOver shadow = 3 redundant visual signals. Drop target reads instantly.\n\n### 2. Muted channel a11y redundancy (audit:sidebar Gap #4)\n\nMuted rows used `opacity: 0.55` as the ONLY signal — borderline-WCAG for low-vision users when paired with a still-visible mention badge. Added a 4th case to the existing boxShadow ternary chain:\n\n```diff\n  : unread > 0 && !isActive\n    ? 'inset 4px 0 0 0 var(--color-module-chat)'\n+   : isMuted && !isActive\n+     ? 'inset 2px 0 0 0 color-mix(in oklch, var(--fg-default) 22%, transparent)'\n    : undefined\n```\n\nA faint 2 px inset left strip in the foreground color, only when the row is `isMuted && !isActive && !unread`. Pairs with the mute icon + the opacity dimming to give 3 redundant signals: icon, opacity, strip.\n\n### 3. Saved + Pulse popover scroll-fade overlays (audit:sidebar Gap #8)\n\nBoth popovers had `max-h-[420/460px] overflow-y-auto` with no visual signal when content extends below the fold — users could miss that there were more items. Added a stable 32 px bottom-fade overlay:\n\n```jsx\n<div className=\"relative\">\n  <div className=\"max-h-[420px] overflow-y-auto\">\n    {/* content */}\n  </div>\n  <div\n    aria-hidden\n    className=\"pointer-events-none absolute inset-x-0 bottom-0 h-8\"\n    style={{\n      background: 'linear-gradient(to bottom, transparent, var(--bg-popover, var(--bg-default)))',\n    }}\n  />\n</div>\n```\n\nThe gradient sits OUTSIDE the scroller but inside the `relative` wrapper. `position: absolute` inside an `overflow` parent positions against the padding box, NOT the scroll content — so it stays at the visible bottom regardless of scroll position. `pointer-events: none` keeps clicks falling through to the row underneath.\n\n**Verification:** chat 107/107 tests pass; touched files typecheck clean.\n\n**Sources:** audit:sidebar Gaps #3, #4, #8 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.002Z","updatedAt":"2026-06-05T01:29:11.002Z"},{"id":"05fb7c1a-ebf7-4afb-9dd4-c474de9f1ae7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-33-dark-glass-tightening","type":"changed","scope":"chat","summary":"Round 33 — dark-mode chat popovers get a tightened glassmorphism recipe (96% opacity + bright inset border highlight + deeper drop shadow) so they read as floating elements instead of fading into inky backgrounds.","body":"Direct response to 2026 design research that surfaced in every glassmorphism source: dark mode exposes glassmorphism's weaknesses. Translucent layers fade into inky backgrounds, making panels nearly invisible.\n\nHelios's chat popovers (Cmd+K palette, quick-react palette, mention popover, search modal, channel-header pinned/threads/files popovers — every surface tagged `data-helios-chat-popover`) all use a unified `92% bg-popover + 14 px blur + 150% saturation` recipe. In light mode it sings. In dark mode the resolved color values produce:\n\n- `--bg-popover` → `oklch(~0.18)` (deep slate)\n- `92%` of that mixed with `transparent` → ~17% lightness\n- Page backdrop → `oklch(~0.16)` (chat panel)\n- **Net: the popover sits ~1% above the page = a barely-visible smear**\n\n**Fix (dark-mode-only override):**\n\n```css\n[data-theme=\"dark\"] [data-helios-chat-popover] {\n  /* 92% → 96% — opaque enough to separate from the inky page */\n  background: color-mix(in oklch, var(--bg-popover) 96%, transparent);\n\n  /* Replace muted border with bright inset highlight + deeper shadow */\n  box-shadow:\n    inset 0 1px 0 0 color-mix(in oklch, white 8%, transparent),\n    0 12px 32px -12px rgba(0, 0, 0, 0.6),\n    0 4px 10px -3px rgba(0, 0, 0, 0.3),\n    0 0 0 1px color-mix(in oklch, white 4%, transparent);\n}\n```\n\nThe 1 px inset highlight at the top is the canonical \"glass edge catches the light\" effect from Apple's Vibrancy + Material Design's surface tinting — it tells the eye \"this is a raised surface\" without needing a heavy border.\n\n**Light mode unchanged.** The existing 92% + soft outer ring continues to work where the page backdrop is bright.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources researched:**\n- Medium \"Dark Glassmorphism: The Aesthetic That Will Define UI in 2026\"\n- StudioLimb \"Glassmorphism CSS Tutorial: How to Create Frosted Glass UI (2026)\"\n- Orizon \"Glassmorphism in 2026: How to Use Frosted Glass Without Killing UX\"\n- CSS Studio \"Glassmorphism in CSS: The Complete Guide to Frosted Glass Effects\"","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:10.996Z","updatedAt":"2026-06-05T01:29:10.996Z"},{"id":"934c4217-682c-462c-a38b-8b299a594658","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-56-sidebar-section-header","type":"changed","scope":"chat","summary":"Round 56 — sidebar section headers (Channels / DMs / Pinned) and empty-section hints adopt the shared `helios-chat-header-glass` recipe + fluid type tokens (caption / micro / meta) so they scale with the rest of the chat system on ultrawide displays.","body":"The sidebar sections (Channels, Direct messages, Pinned, etc.) each have a sticky `SectionHeader` at the top — these used a one-off `backdrop-filter: blur(8px)` that drifted from the round-49 `helios-chat-header-glass` recipe (`blur(12px) saturate(140%)`), and three hardcoded font-size literals (11 / 10 / 11.5 px) that stayed flat on ultrawide while the rest of the chat scale crept up.\n\n### Changes\n\n**1. `SectionHeader` adopts the shared glass class:**\n\n```diff\n- className=\"sticky top-0 z-[1] flex items-center justify-between px-3 pt-3 pb-1.5\"\n+ className=\"helios-chat-header-glass sticky top-0 z-[1] flex items-center justify-between px-3 pt-3 pb-1.5\"\n  style={{\n    background: 'color-mix(in oklch, var(--bg-default) 92%, transparent)',\n-   backdropFilter: 'blur(8px)',\n  }}\n```\n\nThe class owns `backdrop-filter: blur(12px) saturate(140%)` (round 49). Inline `style.background` stays because the section header has its own bg tint independent of the channel/thread headers.\n\n**2. Section label fluid type:**\n\n```diff\n- className=\"flex items-center gap-1.5 font-semibold text-[11px] uppercase tracking-[0.1em]\"\n+ className=\"flex items-center gap-1.5 font-semibold uppercase tracking-[0.1em]\"\n+ style={{ fontSize: 'var(--text-chat-caption, 11px)', color: 'var(--fg-muted)' }}\n```\n\n`tracking-[0.1em]` is kept — section labels are read at-a-glance from across the sidebar; the looser tracking helps. Chip labels (round 54) use `0.08em` because they're scanned inline with the metadata cluster.\n\n**3. Count-pill fluid type:**\n\n```diff\n- className=\"rounded-full px-1.5 py-px text-[10px] font-semibold tabular-nums\"\n+ className=\"rounded-full px-1.5 py-px font-semibold tabular-nums\"\n+ style={{ fontSize: 'var(--text-chat-micro, 10px)', background: 'var(--bg-emphasis)', color: 'var(--fg-faint)' }}\n```\n\n**4. `EmptyHint` fluid type:**\n\n```diff\n- className=\"rounded-md border border-dashed px-3 py-3 text-center text-[11.5px] leading-snug\"\n+ className=\"rounded-md border border-dashed px-3 py-3 text-center leading-snug\"\n+ style={{ fontSize: 'var(--text-chat-meta, 11.5px)', ... }}\n```\n\n### Net visual effect\n\nOn a standard 1440 px laptop: imperceptible. On a 32\" ultrawide: section labels grow from 11 px → ~11.4 px alongside the rest of the chat scale; count pills grow from 10 px → ~10.4 px; empty-section copy from 11.5 px → ~12.3 px. The sidebar finally scales as one coherent system instead of pinned-to-laptop literals + clamp-everything-else.\n\n**Verification:** chat 107/107 tests pass; sidebar typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.566Z","updatedAt":"2026-06-05T01:29:11.566Z"},{"id":"112d8fd1-3294-4546-a7a4-8db13426fa64","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-85-captions-pause-resume","type":"added","scope":"chat","summary":"Round 85 — huddle live captions get a Pause / Resume toggle. Paused captions hide the overlay but keep recognition running, so resume picks up the current line instantly without a reconnection lag.","body":"Surfaced by huddle audit gap #5 (workflow `wf_9ef41eb9-bc5`).\n\nLive captions were all-or-nothing: enable them via the toolbar, disable them via the toolbar, no in-between. Real scenario from the audit: user enables captions, then screen-share starts, and the bottom-of-stage overlay crowds the shared content. Or the captions get noisy + wrong (background noise, multiple speakers overlapping). Disabling-then-re-enabling means a SpeechRecognition tear-down + reconnect, which costs 1-2 seconds + can lose the current speaker's line.\n\n### What lands\n\nNew `captionsPaused` state alongside `captionsEnabled`. The recognition stream keeps running when paused — only the OVERLAY hides:\n\n```ts\nconst [captionsPaused, setCaptionsPaused] = useState(false);\n```\n\n```tsx\n{/* Overlay only renders when enabled AND not paused */}\n{captionsEnabled && captions.length > 0 && !captionsPaused && (\n  <div className=\"... pointer-events-none ...\" aria-live=\"polite\" aria-atomic=\"false\">\n    {captions.map(...)}\n    <button\n      type=\"button\"\n      onClick={() => setCaptionsPaused(true)}\n      className=\"pointer-events-auto absolute -top-3 right-1 ...\"\n      aria-label={tt('chat.huddle.pause_captions', 'Pause captions')}\n    >\n      {tt('chat.huddle.pause_short', 'Pause')}\n    </button>\n  </div>\n)}\n\n{/* When paused: tiny \"Resume\" pill at the same anchor */}\n{captionsEnabled && captionsPaused && (\n  <button onClick={() => setCaptionsPaused(false)} ...>\n    {tt('chat.huddle.captions_paused_resume', 'Captions paused — Resume')}\n  </button>\n)}\n```\n\n### Design notes\n\n- **Pause button positioned above the overlay** (`absolute -top-3 right-1`) — sits floating above the caption text so the user sees the affordance without it taking up overlay real estate.\n- **`pointer-events-none` on the overlay; `pointer-events-auto` on the Pause button alone** — the overlay still doesn't block clicks on the video underneath; only the small button captures clicks.\n- **Resume pill sits at the same anchor** — wherever the user paused from, the resume is in the same spot. No hunting.\n- **Recognition NOT torn down on pause** — the existing `useEffect([captionsEnabled, room])` recognition lifecycle keeps running; the UI just stops painting. Resume = instant text return. Disable from the toolbar still tears down properly.\n- **`aria-atomic=\"false\"`** added to the overlay so screen readers announce only NEWLY appended captions, not the entire stream on every update (would stutter).\n\n### What we did NOT add\n\n- **Per-speaker mute** — would need a UI for \"stop captioning Alice\" — separate UX problem.\n- **Saved-state on pause** — pause flips back to \"false\" on next mount (e.g., re-join). For now that's correct (re-joining a call is a fresh session).\n- **`aria-live=\"off\"` toggling instead of unmount** — tried; some screen readers don't reliably toggle live regions mid-flight. Conditional render is cleaner.\n\n3 new i18n keys: `chat.huddle.pause_captions`, `chat.huddle.pause_short`, `chat.huddle.resume_captions`, `chat.huddle.captions_paused_resume`.\n\n**Verification:** chat 107/107 tests pass; huddle-stage typecheck clean.\n\n**Sources:** huddle audit gap #5 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.498Z","updatedAt":"2026-06-05T01:29:12.498Z"},{"id":"f05245c9-c474-4ee0-9504-8e1f1ff351d1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-add-deal-probability-from-stage","type":"changed","scope":"crm","summary":"Picking a stage in the \"Add deal\" form now pre-fills the win probability from that stage.","body":"In the \"Add deal\" form, choosing a pipeline stage now sets the win-probability\nfield to that stage's configured probability (you can still override it), so new\ndeals start with a sensible, stage-consistent likelihood instead of a fixed\ndefault.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.848Z","updatedAt":"2026-06-05T01:29:12.848Z"},{"id":"4276718a-7118-4e12-9dde-5160f262e99b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-contacts-panel","type":"added","scope":"crm","summary":"The deal record now has a Contacts panel to add people with roles, set a primary, and remove them.","body":"The deal detail page gained a **Contacts** panel (in the right sidebar). It\nlists everyone linked to the deal with their buying role and a star on the\nprimary contact, and — for users who can edit deals — lets you add a contact\n(searchable picker) with a role, promote one to primary, or remove it. The first\ncontact added is made primary automatically. It drives the DM-3 deal↔contact\nactions, so the deal's headline contact stays in sync everywhere.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.033Z","updatedAt":"2026-06-05T01:29:13.033Z"},{"id":"05c2ae50-8798-43cf-8f2c-b47ec0be45dc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-routing-country-ui","type":"added","scope":"payments","summary":"The payment routing rules editor now has a payer-country field.","body":"The routing rules editor (Settings → Payments) now shows and edits a payer\ncountry per rule: `*` for any country, an ISO code like `US`, or a region such as\n`EU-*` or `APAC-*`. A new Country column appears in the rules table, and the\nadd/edit dialog explains how country specificity ranks (below currency, above\namount) and that it's taken from the invoice's billing country at link time.\nThis surfaces the geo-aware routing engine shipped on the backend.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.642Z","updatedAt":"2026-06-05T01:29:13.642Z"},{"id":"d35acd4b-1e78-4c73-bd62-4693488f3b5e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-project-collab-a11y-polish","type":"fixed","scope":"projects","summary":"Polished the client-portal project view — accessible request-changes dialog, error states, labelled inputs, clearer status.","body":"A round of accessibility and resilience polish on the client-portal project\ncollaboration surfaces (the client view and the operator-side message/file\npanels):\n\n- The milestone **\"Request changes\"** flow now opens a proper accessible dialog\n  with a notes textarea instead of a `window.prompt()` (screen-reader hostile,\n  off-pattern).\n- The client message thread and file list, and both operator-side panels, now\n  render a distinct **error state** when their data fails to load — previously a\n  failed fetch looked identical to \"no messages / no files\".\n- Loading skeletons now match the final layout (message bubbles, file rows) to\n  avoid reflow, and carry `aria-busy` / `aria-live` so assistive tech announces\n  the async state.\n- The message textarea and the hidden file inputs gained accessible labels.\n- Milestone approval status (Approved / Changes requested) is now a badge with a\n  background **and** an icon, not color alone (WCAG 1.4.1), and the milestone\n  progress bar respects `prefers-reduced-motion`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.719Z","updatedAt":"2026-06-05T01:29:14.719Z"},{"id":"ee134a11-0c39-4dad-9862-9c902fed5fe9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-37-a11y-semantics-focus","type":"changed","scope":"chat","summary":"Round 37 — message stream gets `role=\"log\"` + accessible name (W3C-blessed chronological-append semantics); focus-visible rings on hover-action toolbar + quick-react palette + jump-to-latest pill; dynamic send-button aria-label.","body":"Surfaced by the chat-a11y research (Pattern 1, 4, 5) and audit:message-item Gaps #1, #4, #6. Three independent a11y wins shipped together because they share the same code path (chat-stream reading).\n\n### 1. `role=\"log\"` on the message scroller\n\nThe dedicated `log` role has implicit `aria-live=\"polite\"` + `aria-atomic=\"false\"` + the unique \"only the newly appended entry is novel\" semantic that no plain `aria-live` region can express. Plain `aria-live` on a virtualised list risks re-announcing reordered or remeasured rows; `role=\"log\"` is what the WAI guidance (ARIA23 technique) calls for chronological append-only streams.\n\n```diff\n  <div\n    ref={scrollRef}\n    onScroll={onScroll}\n+   role=\"log\"\n+   aria-label={tt('chat.message_list.aria_log_label', 'Messages')}\n+   aria-live=\"polite\"\n+   aria-relevant=\"additions\"\n    className=\"helios-chat-column-fade-in relative min-h-0 flex-1 overflow-y-auto …\"\n  >\n```\n\nThe `aria-relevant=\"additions\"` extra hint tells screen readers to ignore removals/text changes — virtualisation prunes off-screen rows constantly; those aren't news to the user.\n\n### 2. Focus-visible rings on hover-only chrome\n\nThe hover-actions toolbar (`.helios-msg-actions` group-hover:flex) and quick-react palette emoji buttons (`.helios-chat-react-emoji`) are reachable via Tab but had no visible focus ring — they're styled around `:hover`. Sighted keyboard users had no signal which button was focused.\n\n```css\n.helios-msg-actions button:focus-visible,\n.helios-chat-react-emoji:focus-visible {\n  outline: none;\n  box-shadow:\n    0 0 0 2px var(--bg-default),\n    0 0 0 4px color-mix(in oklch, var(--color-module-chat) 75%, transparent);\n}\n```\n\nModule-chat accent so the ring reads as part of the chat family. The 2 px inner offset against the popover bg, then 2 px of accent — Apple HIG / WCAG-style donut ring.\n\nThe jump-to-latest pill gets the equivalent Tailwind utility:\n\n```diff\n- className=\"… active:translate-y-0\"\n+ className=\"… focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-[var(--color-module-chat)] focus-visible:ring-offset-[var(--bg-default)] active:translate-y-0\"\n```\n\n### 3. Dynamic send-button aria-label\n\nWas: `aria-label=\"Send message\"` regardless of state. Screen-reader users heard \"Send, disabled\" with no explanation when uploads were in flight or the editor was empty.\n\n```diff\n- aria-label={submitLabel ?? tt('chat.composer.send_message', 'Send message')}\n+ aria-label={\n+   submitLabel ??\n+   (localFiles.size > 0\n+     ? tt('chat.composer.send_pending_uploads', 'Send — waiting for attachments to upload')\n+     : sendDisabled\n+       ? tt('chat.composer.send_empty', 'Send — type a message first')\n+       : tt('chat.composer.send_message', 'Send message'))\n+ }\n```\n\nThree keys are new in i18n; existing `chat.composer.send_message` unchanged.\n\n**Verification:** chat 107/107 tests pass; web typecheck clean on touched files.\n\n**Sources:**\n- [ARIA: log role — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/log_role)\n- [ARIA23: Using role=log — W3C WAI](https://www.w3.org/WAI/WCAG21/Techniques/aria/ARIA23)\n- [ARIA live regions — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)\n- [Accessible notifications with ARIA Live Regions — Sara Soueidan](https://www.sarasoueidan.com/blog/accessible-notifications-with-aria-live-regions-part-1/)\n- chat-a11y research lens (workflow `wf_14d2b01a-8de`) + audit:message-item","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.153Z","updatedAt":"2026-06-05T01:29:11.153Z"},{"id":"8731226a-5d23-433c-885d-5ffb31f4a819","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-48-voice-recorder-shape-cue","type":"changed","scope":"chat","summary":"Round 48 — voice recorder phase indicator uses SHAPE in addition to color (filled circle / two bars / hollow ring) so colorblind users can distinguish recording / paused / stopped without relying on the red/grey/accent encoding.","body":"Surfaced by audit:composer Gap #6. WCAG 1.4.1 (Use of Color) — color must not be the only visual means of conveying information.\n\nThe voice recorder rendered a single 8 × 8 px circle that changed COLOR per phase: red (recording), grey (paused), accent-purple (stopped). Three states encoded only by color = deuteranopia / protanopia users could not distinguish stopped vs recording in particular (both red-ish on common forms).\n\n**Fix:** three distinct SHAPES, each matching universal media-control vocabulary:\n\n```jsx\n{phase === 'recording' && (\n  <span aria-hidden className=\"size-2 animate-pulse rounded-full\"\n        style={{ background: 'var(--accent-danger)' }} />\n)}\n{phase === 'paused' && (\n  <span aria-hidden className=\"flex items-center gap-[2px]\">\n    <span style={{ width: 2, height: 8, background: 'var(--fg-muted)', borderRadius: 1 }} />\n    <span style={{ width: 2, height: 8, background: 'var(--fg-muted)', borderRadius: 1 }} />\n  </span>\n)}\n{phase === 'stopped' && (\n  <span aria-hidden className=\"size-2 rounded-full\"\n        style={{ background: 'transparent', border: '1.5px solid var(--color-module-chat)' }} />\n)}\n```\n\n- **recording** → filled pulsing red circle (live signal — universal)\n- **paused** → two parallel vertical bars (universal pause glyph)\n- **stopped** → hollow ring (universal \"off\" / \"completed\" mark)\n\nThe existing status text below (\"Recording…\" / \"Paused\" / \"Ready to send\") provides the screen-reader path; the shape is for sighted users who can't perceive the color difference. `aria-hidden` on each shape keeps the text from being announced twice.\n\n`boxSizing: 'border-box'` on the hollow-ring variant so the 1.5 px border doesn't push the visual outside the 8 × 8 px footprint (would have caused 1 px row jitter between phases otherwise).\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:**\n- audit:composer Gap #6 (workflow `wf_14d2b01a-8de`)\n- [WCAG 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG21/Understanding/use-of-color.html)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.295Z","updatedAt":"2026-06-05T01:29:11.295Z"},{"id":"f833788d-660b-48e6-af59-a73c13abebe5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-79-message-detail-cmd-enter","type":"added","scope":"chat","summary":"Round 79 — `MessageDetailModal` gets `Cmd/Ctrl+Enter` to open the thread directly. New kbd-hint chip on the \"Open thread\" button shows the shortcut on `sm+`; `aria-keyshortcuts` announces it to AT.","body":"Surfaced by modal audit gap #7 (workflow `wf_9ef41eb9-bc5`).\n\nThe MessageDetailModal opens when a user clicks a message permalink, a search-result hit, or expands a message from the activity feed. Once open, the most-likely next action is jumping into the message's thread (if one exists). Before this round, users had to Tab through the modal to the \"Open thread\" button — 4-7 Tabs depending on what surfaces have focus (reactor lookup is async + can steal focus, the close X is in a separate cluster).\n\n### What lands\n\n**Cmd+Enter / Ctrl+Enter** opens the thread when one exists + the parent passed `onOpenThread`:\n\n```ts\nuseEffect(() => {\n  if (!hasThread || !onOpenThread) return;\n  const fire = onOpenThread;\n  function onKey(e: KeyboardEvent) {\n    if (!(e.metaKey || e.ctrlKey)) return;\n    if (e.key !== 'Enter') return;\n    // Don't hijack when typing in inputs (reactor-emoji search field, etc).\n    const target = e.target as HTMLElement;\n    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {\n      return;\n    }\n    e.preventDefault();\n    fire(message.id);\n    onClose();\n  }\n  document.addEventListener('keydown', onKey);\n  return () => document.removeEventListener('keydown', onKey);\n}, [hasThread, message.id, onClose, onOpenThread]);\n```\n\nThe handler is **document-scoped** (not modal-scoped) because the modal's focus may be on the Close X, the Copy-link button, or the reactor list — anywhere inside the dialog. Document-keydown catches the chord regardless of the focused descendant. The input/textarea guard prevents the chord from firing inside the (future) reaction-emoji search box.\n\n### Visual signal\n\nThe \"Open thread\" button shows the shortcut as a kbd chip on `sm+`:\n\n```tsx\n<button aria-keyshortcuts=\"Meta+Enter Control+Enter\">\n  <ChatTeardrop size={11} weight=\"bold\" />\n  <span>Open thread</span>\n  <kbd className=\"hidden ml-1 ... sm:inline-flex\">⌘↵</kbd>\n</button>\n```\n\n`aria-keyshortcuts=\"Meta+Enter Control+Enter\"` announces the chord on focus (matches round 45's existing pattern for Cmd+K palette + Cmd+Shift+F sidebar filter + Cmd+Shift+M search). The `<kbd>` chip is hidden on `<sm` per the round-28 kbd-hint hide convention — touch users don't have Cmd/Ctrl.\n\n### Why a closure-captured `const fire`\n\nTypeScript can't narrow `onOpenThread` inside the inner `onKey` callback (closure capture + flow analysis limitation). Capturing into a local `const fire = onOpenThread` after the early-return guard tells TS the value is non-nullable inside `fire(message.id)`. Pure ergonomics — same runtime behaviour.\n\n**Verification:** chat 107/107 tests pass; message-detail-modal typecheck clean.\n\n**Sources:** modal audit gap #7 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.295Z","updatedAt":"2026-06-05T01:29:12.295Z"},{"id":"15286bc6-f69a-4600-80f0-d0d1d65aa543","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-87-huddle-roster-focus","type":"changed","scope":"chat","summary":"Round 87 — huddle RosterSidebar gets focus management on open/close: auto-focuses the aside on mount (AT users hear \"Participants region\"), Esc closes it, focus returns to the toggle button on unmount. Plus `aria-expanded` + `aria-controls` on the trigger.","body":"Surfaced by huddle audit gap #8 (workflow `wf_9ef41eb9-bc5`).\n\nThe RosterSidebar (visible-participant list in the huddle stage) was conditionally rendered via `{rosterOpen && <RosterSidebar />}`. The toggle worked, the `aria-pressed` flipped on the button — but keyboard / SR users had no focus signal when the sidebar opened, no Esc handler to close it, and no focus restoration when it closed (focus stranded on `document.body`).\n\n### What lands\n\n**1. Trigger button gets richer ARIA + focus ring:**\n\n```diff\n  <button\n+   ref={rosterToggleRef}\n    type=\"button\"\n    onClick={() => setRosterOpen((o) => !o)}\n-   className=\"... transition-colors hover:bg-white/8\"\n+   className=\"... transition-colors hover:bg-white/8 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40\"\n    aria-pressed={rosterOpen}\n+   aria-expanded={rosterOpen}\n+   aria-controls=\"helios-huddle-roster\"\n    ...\n  >\n```\n\n`aria-expanded` is the canonical attribute for \"this button toggles a region's visibility\" — `aria-pressed` covers the toggle state but `aria-expanded` tells AT specifically that an expandable region is involved. Paired with `aria-controls` pointing at the sidebar's `id=\"helios-huddle-roster\"`, AT users get the relationship.\n\n**2. RosterSidebar focus lifecycle:**\n\n```ts\n// Focus the sidebar on mount → \"Participants region\" announces\nuseEffect(() => { asideRef.current?.focus(); }, []);\n\n// Esc closes (matches modal dismiss convention)\nuseEffect(() => {\n  if (!onClose) return;\n  function onKey(e: KeyboardEvent) {\n    if (e.key === 'Escape') onClose?.();\n  }\n  document.addEventListener('keydown', onKey);\n  return () => document.removeEventListener('keydown', onKey);\n}, [onClose]);\n\n// Restore focus to the trigger on unmount\nuseEffect(() => {\n  return () => { restoreFocusTo?.current?.focus(); };\n}, [restoreFocusTo]);\n```\n\n**3. `<aside>` element changes:**\n\n```diff\n  <aside\n+   ref={asideRef}\n+   tabIndex={-1}\n+   id=\"helios-huddle-roster\"\n+   className=\"... focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 ...\"\n    aria-label={tt('chat.huddle.participants', 'Participants')}\n  >\n```\n\n- `tabIndex={-1}` → programmatically focusable (so the mount-effect can fire) but skipped in natural Tab order. AT users get the announcement; sighted keyboard users don't get a useless Tab stop.\n- `id` → target of the trigger's `aria-controls`.\n- Focus-visible ring (white at 40% opacity, matching the huddle stage's white-on-dark vocab).\n\n**4. New props on RosterSidebar:**\n\n```ts\nonClose?: () => void;\nrestoreFocusTo?: React.RefObject<HTMLElement | null>;\n```\n\nBoth optional → consumers that don't care about focus management (none today, but defensive) don't break.\n\n### What we did NOT add\n\n- **Full focus trap inside the sidebar** — would need a FocusTrap library or `inert` on siblings. Audit gap is partially addressed here (entry + exit + Esc) but not the trap-while-open. Sighted keyboard users can still Tab out of the sidebar back into the stage. Acceptable for a roster (read-mostly view) — would matter more for an editable panel.\n- **Click-outside-to-close** — same reasoning; the sidebar is read-mostly, click-outside dismissal would surprise users mid-scan.\n\n**Verification:** chat 107/107 tests pass; huddle-stage typecheck clean.\n\n**Sources:** huddle audit gap #8 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.563Z","updatedAt":"2026-06-05T01:29:12.563Z"},{"id":"9b57d4f5-5afd-456e-bc10-3513773b7216","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"email-attachments-actually-send","type":"fixed","scope":"email","summary":"Email attachments (e.g. the invoice PDF on a payment receipt) are now actually sent — previously they were persisted but dropped at dispatch.","body":"Payment-receipt emails said \"A copy of the invoice is attached as a PDF\" but arrived with nothing attached. Two gaps in the outbound path caused it:\n\n1. **The drain worker dropped attachments.** `email.outbound.send` persisted the attachment to the outbound row (`attachments_json`), but the worker that dispatches the row to the provider never read it back into the provider request — so every attachment was silently lost at send time. The drain now maps the row's stored inline (base64) attachments into the `SendRequest`.\n2. **The minimal SMTP provider rejected attachments.** It hard-failed any message carrying an attachment (which would have made receipt emails fail entirely once gap 1 was fixed). It now builds a proper `multipart/mixed` MIME message with base64-encoded attachment parts (wrapped at 76 chars), so the PDF actually rides along — including on local SMTP/Mailpit dev setups.\n\nThe HTTP providers (Postmark, Resend, SES, Gmail, MS Graph) already supported attachments; this connects them to the queue and brings the minimal SMTP provider up to parity. Covered by new MIME-composition unit tests.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.337Z","updatedAt":"2026-06-05T01:29:13.337Z"},{"id":"9ca953ff-9790-4e88-b1de-4c0156de7dc9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"pdf-per-rate-tax-breakdown","type":"changed","scope":"sales","summary":"Invoice / quotation / credit-note PDFs now show tax broken out per rate (e.g. \"VAT (20%)\", \"VAT (5%)\") instead of one opaque \"Tax\" total.","body":"The generated PDFs always collapsed tax into a single \"Tax\" line, even when the line items carried several rates — despite the template's own docs promising a per-rate breakdown. The totals block now prints one labeled row per tax rate the document's lines actually use (the rate's name + percentage, e.g. \"VAT (20%) — …\"), which VAT/GST jurisdictions commonly require on a compliant invoice. It falls back to a single \"Tax\" row when no rate detail is available and is omitted when there's no tax. Applies to invoice, quotation, and credit-note PDFs (operator + public token-gated paths), computed once in the shared loader by grouping each line's tax by its rate. Covered by a multi-rate render test.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.563Z","updatedAt":"2026-06-05T01:29:14.563Z"},{"id":"e3d4a79f-4cc0-495c-84bb-e44fa8f8e359","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-36-dark-message-pulse-boost","type":"changed","scope":"chat","summary":"Round 36 — dark-mode `.helios-message-new` + `.helios-message-focused` pulses get a stronger tint (12% → 20%, 18% → 28%) so message arrivals + search-jumps stay visible on the inky chat backdrop. Light mode untouched.","body":"Surfaced by the chat-CSS audit (Critical Gap #4) and bridges round 33's dark-mode glassmorphism fix into the message stream itself.\n\nThe pulse keyframes were tuned for the light backdrop (`var(--bg-default)` resolved to high-luminance oklch). On dark mode, the resolved values land at:\n\n- `--color-module-chat` at 12% mix over `oklch(~0.16)` dark chat panel → ~2% lightness above backdrop = barely visible\n- 18% mix at search-jump → ~4% above backdrop = noticeable but easily missed\n\n**Fix (dark-mode-only variant keyframes):**\n\n```css\n[data-theme=\"dark\"] .helios-message-new       { animation-name: helios-message-new-pulse-dark;       }\n[data-theme=\"dark\"] .helios-message-focused   { animation-name: helios-message-focused-pulse-dark;   }\n\n@keyframes helios-message-new-pulse-dark {\n  0%   { background: color-mix(in oklch, var(--color-module-chat) 20%, transparent); … }\n  100% { background: transparent; … }\n}\n@keyframes helios-message-focused-pulse-dark {\n  0%   { background: color-mix(in oklch, var(--color-module-chat) 28%, transparent); }\n  60%  { background: color-mix(in oklch, var(--color-module-chat) 22%, transparent); }\n  100% { background: transparent; }\n}\n```\n\nThe `*-pulse-dark` keyframes mirror the original timing curves — only the tint percentages are bumped — so the motion vocabulary stays consistent across themes.\n\nLight mode unchanged. Reduced-motion users already get the round-35 fadeIn snap.\n\n**Verification:** chat 107/107 tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.163Z","updatedAt":"2026-06-05T01:29:11.163Z"},{"id":"638f0c6a-33dd-4be3-939a-b592f7c9f072","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-51-roving-tabindex","type":"added","scope":"chat","summary":"Round 51 — roving tabindex on message list. Arrow keys move the focused-row cursor, Home / End jump to start / end, Escape blurs. Tab into the list lands on the most recent message (Slack / Linear pattern).","body":"Surfaced by research:a11y-wcag3 Pattern 5 + APG `feed` keyboard interaction pattern. Pairs with round 46's per-message `role=\"article\"` semantics.\n\n### What didn't work before\n\nTwo bad options for sighted keyboard users:\n- If every message has `tabIndex=0`: a channel with 200 messages = 200 Tab stops. Keyboard fatigue.\n- If no message has `tabIndex`: messages are completely unreachable by keyboard — context menu, reactions, thread links all require a mouse.\n\n### What roving tabindex gives\n\nExactly ONE row has `tabIndex=0` at a time (the \"cursor\"). Arrow keys move the cursor without changing the focused element via Tab. Slack, Linear, Discord all use this pattern. APG-blessed for `feed` and `listbox` widgets.\n\n### Implementation\n\n```ts\n// New state in MessageList:\nconst [focusedIdx, setFocusedIdx] = useState<number | null>(null);\n\n// Default: last message is tabbable when focusedIdx hasn't been set\n// (so Tab into the list lands on the most recent).\nconst effectiveFocusedIdx = focusedIdx ?? messages.length - 1;\n\n// Per-row wrapper:\n<div\n  data-index={vRow.index}\n  tabIndex={vRow.index === effectiveFocusedIdx ? 0 : -1}\n  onFocus={(e) => {\n    if (e.target === e.currentTarget) setFocusedIdx(vRow.index);\n  }}\n>\n\n// Scroll container:\nonKeyDown={(e) => {\n  // ArrowUp / Down → ±1; Home / End → 0 / last; Escape → blur\n  // Don't hijack when typing in composer / inputs\n  // Don't hijack when focus is inside a row's chip / button\n}}\n\n// After focusedIdx changes via keyboard:\n//   virt.scrollToIndex(idx, { align: 'center' });\n//   requestAnimationFrame(() => row.focus());\n```\n\nThe `requestAnimationFrame` wait is critical: the virtualiser may have unmounted the focused row, so we scroll first (which re-mounts) then focus on the next frame.\n\n### Focus-visible ring\n\n```css\n[data-helios-chat] [data-index]:focus-visible {\n  outline: none;\n  box-shadow: inset 0 0 0 2px color-mix(in oklch, var(--color-module-chat) 70%, transparent);\n  border-radius: var(--radius-sm);\n}\n```\n\nModule-chat accent ring, inset 2 px so it doesn't paint past the rounded corner. Only fires on keyboard focus — mouse clicks DON'T paint the ring (that's what `:focus-visible` is for).\n\n### What we did NOT bind\n\n- **Enter** — left for the focused row's inner chips / buttons to claim. Pressing Enter on a row when a thread chip is focused opens the thread; on the row itself, it's a no-op (could be made to open the long-press context menu, but that's a follow-up).\n- **PageUp / PageDown** — would interfere with the browser's natural scroll. Users get vertical scroll for free.\n- **Single-character shortcuts** (R to reply, E to edit) — per WCAG 2.1.4, single-key shortcuts need a settings toggle to remap / disable; that toggle doesn't exist yet.\n\n**Verification:** chat 107/107 tests pass; message-list.tsx typecheck clean.\n\n**Sources:**\n- [APG: Keyboard Interface — Developing a Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/)\n- [APG: Feed pattern](https://www.w3.org/WAI/ARIA/apg/patterns/feed/)\n- research:a11y-wcag3 Pattern 5 (workflow `wf_14d2b01a-8de`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.329Z","updatedAt":"2026-06-05T01:29:11.329Z"},{"id":"b3102e63-8c66-4f5d-a7fb-5caa4e97d175","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-70-huddle-rec-reduced-motion","type":"fixed","scope":"chat","summary":"Round 70 — huddle recording REC pill gets `role=\"status\"` + `aria-live=\"polite\"` so AT announces recording start/stop. Pulsing dot moves from unguarded `animate-pulse` to `helios-pulse-dot` (reduced-motion-aware via round 44's block).","body":"Surfaced by huddle audit gap #6 (workflow `wf_9ef41eb9-bc5`). Two fixes on one pill.\n\n### 1. Reduced-motion safety on the pulsing red dot\n\nThe REC pill's dot used Tailwind's `animate-pulse` (default 2 s opacity 1 → 0.5 fade) with NO `prefers-reduced-motion` guard. Vestibular-sensitive users saw a constant 2 s pulse in the corner of their screen for the entire recording — exactly the \"decorative loop > 5 s\" pattern WCAG 2.2.2 asks us to suppress.\n\n```diff\n- <span aria-hidden className=\"size-1.5 animate-pulse rounded-full\"\n-       style={{ background: '#ef4444' }} />\n+ <span aria-hidden=\"true\" className=\"size-1.5 rounded-full\"\n+       style={{\n+         background: '#ef4444',\n+         animation: 'helios-pulse-dot 1.4s ease-in-out infinite',\n+       }} />\n```\n\nMoved to the global `helios-pulse-dot` keyframe which is wired into the reduced-motion block (round 44):\n\n```css\n@media (prefers-reduced-motion: reduce) {\n  [style*=\"helios-pulse-dot\"] { animation: none !important; }\n}\n```\n\nVestibular-sensitive users now get a static red dot. The `role=\"status\"` + `aria-label` still convey \"this is recording\".\n\n### 2. `role=\"status\"` + `aria-live=\"polite\"` on the pill wrapper\n\nThe REC pill was a silent visual element. When recording started, AT users never heard \"Recording\" — they had to infer from context or tab around hunting for state. Now:\n\n```diff\n  <span\n    className=\"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 ...\"\n    style={{...}}\n+   role=\"status\"\n+   aria-live=\"polite\"\n    title={...}\n  >\n```\n\n`role=\"status\"` carries implicit `aria-live=\"polite\"` per the WAI spec, but explicit-belt-and-suspenders to make intent clear + survive future Radix portal manipulations. When the pill mounts (recording starts) or unmounts (recording stops), screen readers announce the state change via the live-region behaviour.\n\nAlso tightened `aria-hidden` → `aria-hidden=\"true\"` on the dot for explicit-form consistency with the rest of round 59's polish.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** huddle audit gap #6 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["knowledge / claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.983Z","updatedAt":"2026-06-05T01:29:11.983Z"},{"id":"73e99108-476b-42e2-bd6a-5a82ed4813ee","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-77-huddle-pip-drag-affordance","type":"changed","scope":"chat","summary":"Round 77 — huddle PIP window gains a visual drag-handle grip (28×3 px pill at the top) + `aria-roledescription=\"Draggable picture-in-picture window\"` + `aria-grabbed` during drag. Drag affordance now visible + audible.","body":"Surfaced by huddle audit gap #4 (workflow `wf_9ef41eb9-bc5`).\n\nThe HuddlePip floating window is draggable by clicking-and-dragging anywhere on it — the cursor goes `grab` → `grabbing`, but there's no other affordance. Two problems:\n\n1. **AT users get no signal that the window is draggable.** `role=\"dialog\"` + `aria-label=\"Minimized call · #design\"` only conveys identity, not interaction. Screen-reader users hit the dialog but don't know they can move it.\n2. **Sighted users on touch devices** (no cursor) had no visual indicator at all — no clue this floating box could be repositioned.\n\n### Fix\n\n**ARIA additions:**\n\n```diff\n  <div\n    onPointerDown={onPointerDown}\n    onPointerMove={onPointerMove}\n    onPointerUp={onPointerUp}\n    onPointerCancel={onPointerUp}\n    role=\"dialog\"\n+   aria-roledescription={tt('chat.huddle_pip.aria_role_desc', 'Draggable picture-in-picture window')}\n+   aria-grabbed={dragging || undefined}\n    aria-label={`${tt('chat.huddle_pip.minimized_call', 'Minimized call')} · ${channelLabel}`}\n  >\n```\n\n- `aria-roledescription` is announced verbatim by NVDA / JAWS / VoiceOver when the user enters the dialog. The user hears \"Draggable picture-in-picture window — Minimized call · #design\" — the role + interaction + identity in one announcement.\n- `aria-grabbed` is technically deprecated in ARIA 1.1 (the spec recommends `aria-dropeffect` + native drag-and-drop APIs), but JAWS and NVDA still recognize it for AT users on Windows. Set during the active drag for a \"you're moving this now\" signal. `|| undefined` so the attribute is absent when not grabbed (cleaner than `false` in the DOM).\n\n**Visual grip handle:**\n\n```tsx\n<span\n  aria-hidden=\"true\"\n  className=\"pointer-events-none absolute top-1.5 left-1/2 -translate-x-1/2\"\n  style={{\n    width: 28,\n    height: 3,\n    borderRadius: 9999,\n    background: 'rgba(255,255,255,0.32)',\n    opacity: dragging ? 0.65 : 0.32,\n    transition: 'opacity 150ms ease',\n  }}\n/>\n```\n\nA 28×3 px pill centered at the top of the PIP. 32% white at rest, lifts to 65% white during active drag — subtle pulse-on-grab without competing with the video underneath. `pointer-events: none` so the whole PIP body remains the drag target (not just the grip).\n\nUniversal iOS / Android sheet-handle vocabulary: this is the same shape Apple uses on sheet presentations, and Google Material uses on bottom drawers. Sighted users see \"this object has a handle, so it must be movable\" — exactly the affordance the audit asked for.\n\n**Verification:** chat 107/107 tests pass; huddle-pip typecheck clean.\n\n**Sources:** huddle audit gap #4 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.210Z","updatedAt":"2026-06-05T01:29:12.210Z"},{"id":"b335367b-5e7b-4986-a0f2-91c8d0c5dfb5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-companies-rowclick-detail-error-states","type":"fixed","scope":"crm","summary":"Clicking a company opens its detail page (was the edit drawer); contact + deal detail pages now show a load-error state.","body":"Two CRM consistency/resilience fixes from the audit:\n\n- Clicking a row in the **Companies** list now opens the company's detail page\n  (`/clients/$id`) instead of the edit drawer — matching contacts, leads, and\n  deals. The \"Edit\" row action still opens the drawer.\n- The **contact** and **deal** detail pages now render a distinct \"couldn't\n  load\" error state when their query fails, instead of showing the misleading\n  \"not found\" message for what is actually a transient/permission error.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.859Z","updatedAt":"2026-06-05T01:29:12.859Z"},{"id":"dd4b5f7d-d7dd-4d86-8ec5-48269db236d1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-file-upload-size-guard","type":"fixed","scope":"projects","summary":"Oversized project-file uploads now fail instantly with a clear message instead of after a full upload attempt.","body":"Project file uploads (client portal + operator panel) now check the file size\nclient-side (25 MB, mirroring the storage limit) and show an immediate \"too\nlarge\" message, instead of letting the user wait through a presign + full PUT\nonly to hit a server rejection. The server-side limit remains authoritative.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.845Z","updatedAt":"2026-06-05T01:29:15.845Z"},{"id":"a1486862-76fc-43eb-bc39-25413a266482","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-46-message-article-semantics","type":"changed","scope":"chat","summary":"Round 46 — every chat message row gets `role=\"article\"` + `aria-posinset` + `aria-setsize` + `aria-label=\"<author>, <time>\"`. WCAG 1.3.1 — virtualised-list users finally hear \"message 437 of 2,000\" instead of \"12 messages.\"","body":"Surfaced by research:a11y-wcag3 Patterns 6 + 7. Pairs with round 37's `role=\"log\"` on the scroller — log handles the live-region \"new message arrived\" announcement; article handles the per-row WCAG 1.3.1 grouping (Info and Relationships).\n\n### What screen-reader users heard before\n\nThe virtualiser unmounts off-screen rows. AT saw only the ~12 currently-rendered messages and announced \"12 messages\" — completely wrong for a channel with 2,000 messages of history.\n\n### What they hear now\n\n```diff\n  <div\n    data-msg-continuation={isContinuation ? 'true' : 'false'}\n+   role=\"article\"\n+   aria-posinset={posInSet}      // 1-indexed, from MessageList\n+   aria-setsize={setSize}        // = messages.length (FULL count)\n+   aria-label={`${author}, ${time}`}\n    className={cn(...)}\n```\n\nNavigating by article (NVDA `Insert+F3`, VoiceOver rotor) announces: *\"Article, Ada Lovelace at 2:14 PM, message 437 of 2000\"* — one semantic unit, with position context, instead of three disjoint reads.\n\n### Plumbing\n\n```diff\n  /* message-list.tsx */\n  <MessageItem\n    …\n+   posInSet={vRow.index + 1}    // 1-indexed per ARIA spec\n+   setSize={messages.length}    // full count, not virtualiser count\n  />\n\n  /* message-item.tsx — props */\n+ posInSet?: number;\n+ setSize?: number;\n```\n\nThe `aria-label` composes `author` + `time` — same strings the visible row renders, so AT users hear the same data sighted users see. `aria-labelledby` would be more elegant but would require generating stable IDs on the author + time spans (non-trivial in a virtualised list with continuation row collapsing); `aria-label` is the pragmatic choice and equivalent at the AT level.\n\n### Why not native `<article>` element\n\nSwapping the outer `<div>` to `<article>` would risk regressing any CSS that targets descendant `div`-specific selectors. `role=\"article\"` on a `<div>` gives the same a11y semantic without the visual-tree risk. WCAG-equivalent per [MDN — ARIA: article role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/article_role).\n\n**Verification:** chat 107/107 tests pass; touched files typecheck clean.\n\n**Sources:**\n- [ARIA: article role — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/article_role)\n- [aria-posinset / aria-setsize — MDN](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-posinset)\n- [WCAG 1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG21/Understanding/info-and-relationships.html)\n- research:a11y-wcag3 Patterns 6 + 7 (workflow `wf_14d2b01a-8de`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.201Z","updatedAt":"2026-06-05T01:29:11.201Z"},{"id":"f889f160-0674-4f3e-8793-3ccba3b057e8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-49-header-glass-dry","type":"changed","scope":"chat","summary":"Round 49 — header glass recipe (12 px blur + 140% saturate) extracted to `.helios-chat-header-glass`. Applied to channel-view + thread-pane headers. Thread root-message divider bumped 5% → 6% to match the rest of the chat divider language.","body":"Surfaced by audit:thread-huddle Gaps #1 + #10.\n\n### 1. Header glass class extraction\n\nThe `backdrop-filter: blur(12px) saturate(140%)` recipe appeared inline in 4 places (channel-view header, thread-pane header, sidebar header, message-item action toolbar). Drift risk: if the design system ever changes the blur radius or saturation, all 4 need to be hunted down individually.\n\n```css\n/* apps/web/src/styles.css */\n.helios-chat-header-glass {\n  backdrop-filter: blur(12px) saturate(140%);\n  -webkit-backdrop-filter: blur(12px) saturate(140%);\n}\n```\n\nApplied to:\n- `channel-view.tsx` header (`<header class=\"helios-chat-header-glass ...\">`)\n- `thread-pane.tsx` header (same)\n\nBackground tint stays inline because it varies with `isExternal` + `scrolled` state (channel-view) or is constant 92% (thread). The class only owns the `backdrop-filter` so callers stay flexible.\n\nNOT applied to:\n- `chat-channels-sidebar.tsx` header — different sub-context (filter input row, not channel header). Worth deferring until a deliberate sidebar-glass refactor.\n- `message-item.tsx` action toolbar — popover, not header; different surface family.\n\n### 2. Thread root-message divider opacity 5% → 6%\n\n```diff\n- style={{ borderBottom: '1px solid color-mix(in oklch, var(--fg-default) 5%, transparent)' }}\n+ style={{ borderBottom: '1px solid color-mix(in oklch, var(--fg-default) 6%, transparent)' }}\n```\n\nThe audit flagged that the thread root-message divider at 5% read as \"drifting weaker\" next to the channel-view's 6% on the same screen — breaking the visual hierarchy (the side pane shouldn't look further than the main channel). 1 percentage point bump, restores parity.\n\n**Verification:** chat 107/107 tests pass; touched files typecheck clean.\n\n**Sources:** audit:thread-huddle Gaps #1 + #10 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.297Z","updatedAt":"2026-06-05T01:29:11.297Z"},{"id":"6246f637-1a4a-4413-a5b5-fe6e98eae006","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-50-attachment-upload-shimmer","type":"changed","scope":"chat","summary":"Round 50 — pending attachment chips get a 1.6 s gradient shimmer sweep so users see data is moving on slow networks. Previous pulsing dot couldn't disambiguate \"still uploading\" from \"stalled.\"","body":"Surfaced by audit:composer Gap #1.\n\nThe composer's pending-upload chip used a pulsing dot to signal \"still uploading.\" Problem: the dot looks identical whether the transfer is progressing at 5 MB/s or stalled at 0 KB/s for 30 seconds. On flaky networks (mobile data, conference Wi-Fi) users had no signal whether to wait or to retry.\n\n**Fix:** add a `.helios-chat-upload-shimmer` class that drives a 1.6 s left-to-right gradient sweep over the chip face via a `::after` pseudo-element. Same visual language as a list-item skeleton — the eye reads \"data is moving\" instantly.\n\n```css\n.helios-chat-upload-shimmer {\n  position: relative;\n}\n.helios-chat-upload-shimmer::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  pointer-events: none;\n  background: linear-gradient(\n    100deg,\n    transparent 30%,\n    color-mix(in oklch, var(--color-module-chat) 26%, transparent) 50%,\n    transparent 70%\n  );\n  background-size: 220% 100%;\n  background-position: 100% 0;\n  animation: helios-chat-upload-shimmer 1.6s linear infinite;\n}\n@keyframes helios-chat-upload-shimmer {\n  to { background-position: -120% 0; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .helios-chat-upload-shimmer::after {\n    animation: none;\n    background: none;\n  }\n}\n```\n\nApplied via a single className change on the pending-upload chip wrapper in `composer-tiptap.tsx`:\n\n```diff\n  <div\n    key={p.name}\n-   className=\"relative overflow-hidden rounded-md border\"\n+   className=\"helios-chat-upload-shimmer relative overflow-hidden rounded-md border\"\n```\n\nThe existing `overflow:hidden` on the chip wrapper traps the sweep inside the chip border so it doesn't bleed onto adjacent chrome. The pulsing dot stays as a complementary \"active\" signal — shimmer is for \"progressing\", dot is for \"still alive.\" Both stop when the upload completes and the chip swaps to its canonical attachment-row representation.\n\nReduced-motion users get `animation: none` + `background: none` — no shimmer, no visual change, no implication that something is wrong (the pulsing dot still conveys liveness).\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** audit:composer Gap #1 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.331Z","updatedAt":"2026-06-05T01:29:11.331Z"},{"id":"d426b1b0-3096-4749-82f4-7139ad9c26fe","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-55-ai-avatar","type":"changed","scope":"chat","summary":"Round 55 — AI message rows get a purpose-built 32×32 Sparkle avatar (purple gradient + inset highlight + soft AI-tinted drop shadow + ambient breath animation) instead of generic \"HA\" initials in a Helios-gradient circle.","body":"Before this round, AI message rows rendered the shared `<Avatar name={`${appName} AI`} size=\"md\" />` primitive — which produced initials like \"HA\" (Helios AI) inside a deterministically-tinted circle. Looked anonymous next to user rows; gave no AI-content signal beyond the purple author name + AI pill that already lived in the metadata line.\n\n### What lands\n\n```tsx\nisAi ? (\n  <span\n    aria-hidden\n    className=\"relative inline-flex size-8 shrink-0 items-center justify-center rounded-full\"\n    style={{\n      background: 'linear-gradient(180deg,\n        color-mix(in oklch, var(--color-ai-500) 26%, var(--bg-default)) 0%,\n        color-mix(in oklch, var(--color-ai-500) 14%, var(--bg-default)) 100%)',\n      color: 'var(--color-ai-600)',\n      boxShadow:\n        'inset 0 1px 0 0 color-mix(in oklch, white 30%, transparent),\n         inset 0 0 0 1px color-mix(in oklch, var(--color-ai-500) 22%, transparent),\n         0 1px 3px -1px color-mix(in oklch, var(--color-ai-500) 30%, transparent)',\n    }}\n    title={author}\n  >\n    <Sparkle size={14} weight=\"fill\" className=\"helios-chat-ai-sparkle\" />\n  </span>\n) : (\n  // user-row path unchanged\n  <Avatar name={author} size=\"md\" status={...} />\n)\n```\n\n### Design notes\n\n- **`size-8` exactly matches `Avatar size=\"md\"`** (32 × 32 px) — no layout reflow when AI replies arrive next to user rows.\n- **`.helios-chat-ai-sparkle`** class drives the ambient breath animation already shipped for the Ask Helios pane (round 32-era token). AI rows now subtly \"live\" the same way the side-pane sparkle does.\n- **3-layer box-shadow**: inner-top-white-highlight (catches the eye at the top edge — same trick as the round-54 chip family), inner ring (separates from the surrounding row bg), outer AI-tinted drop shadow (gives the avatar a \"halo\" without using `filter: drop-shadow` which would clip).\n- **Gradient runs DARKER at the top** (26% → 14% AI tint over bg-default). Reversed from the chip family because here the highlight comes from the box-shadow inset, not the background. The gradient compensates so the bottom doesn't read as washed out.\n- `aria-hidden` — the author name + AI pill in the metadata line already convey the AI provenance to screen readers. The avatar is decorative.\n\n### What we did NOT change\n\n- The shared `Avatar` primitive in `packages/ui/src/primitives/avatar.tsx`. Generic-by-design; ERP modules outside chat don't have an AI concept and shouldn't be paying for an `isAi` prop. The custom avatar lives entirely inside `message-item.tsx` where the AI context is already established.\n- Continuation-row AI rows. Continuations don't render any avatar (the gutter shows a hover timestamp instead), so there's no AI avatar in that path either.\n\n**Verification:** chat 107/107 tests pass; message-item.tsx typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.464Z","updatedAt":"2026-06-05T01:29:11.464Z"},{"id":"d12a8f67-46ad-4b01-a13b-3decc8391460","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-60-status-tick-differentiation","type":"changed","scope":"chat","summary":"Round 60 — message status ticks (Sending / Sent / Read) become a 3-step visual ladder. Pending breathes at 60% opacity, sent is solid muted, read pops with the chat-accent drop shadow. No more pending+sent looking identical except for icon glyph.","body":"Surfaced by presence audit gaps #2 + #4 (workflow `wf_9ef41eb9-bc5`). WCAG 1.4.1 (Use of Color) issue: pending and sent both rendered `var(--fg-faint)` with only Clock vs Check icon differentiating. Deuteranopia / protanopia users couldn't distinguish them at a glance, and `animate-pulse` (Tailwind default 1→0.5 fade) wasn't in the chat motion vocabulary.\n\n### Visual ladder now\n\n| Status | Glyph | Color | Motion |\n|---|---|---|---|\n| **Pending** | `<Clock weight=\"regular\" />` 11 px | `--fg-faint` at **0.6 opacity** | breathes via `helios-pulse-dot` (1.2 s ease-in-out) |\n| **Sent** | `<Check weight=\"bold\" />` 12 px | `--fg-muted` (one step brighter) | static |\n| **Read** | `<Checks weight=\"bold\" />` 12 px | `--color-module-chat` + drop shadow | static (already shipped) |\n\nThree distinct glyphs × three distinct color tiers × three distinct motion treatments. Each step is unambiguous on its own; no two states share a treatment.\n\n### Why `helios-pulse-dot` and not `helios-typing-bounce`\n\n`helios-pulse-dot` (the global keyframe at `styles.css:1280`) is opacity 1 → 0.35 → 1 — a slow breathing rhythm that reads as \"in-flight, still alive.\" `helios-typing-bounce` is for the typing-indicator dots and animates `translateY`, which would jitter the Clock glyph against its inline baseline. The two are similar but distinct vocabularies; reusing the right one keeps the chat motion language coherent.\n\n```diff\n- className=\"inline-flex animate-pulse items-center\"\n- style={{ color: 'var(--fg-faint)' }}\n+ className=\"inline-flex items-center\"\n+ style={{\n+   color: 'var(--fg-faint)',\n+   opacity: 0.6,\n+   animation: 'helios-pulse-dot 1.2s ease-in-out infinite',\n+ }}\n```\n\nReduced-motion users are already covered by round 44's `[style*=\"helios-pulse-dot\"] { animation: none !important }` block — the breathing collapses to a static 60%-opacity Clock, which is still distinct from the 100%-opacity Sent Check.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** presence audit gaps #2 + #4 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.664Z","updatedAt":"2026-06-05T01:29:11.664Z"},{"id":"dc698e02-02a8-4c4b-8cf9-7615d17d34ad","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-64-channel-not-found-polish","type":"changed","scope":"chat","summary":"Round 64 — channel-not-found error state gets icon + visual hierarchy + tokenised type + helpful recovery copy. Replaces the bare 2-line text block that gave users no signal whether they hit an error or just a slow load.","body":"Surfaced by empty-state audit gap #2 (workflow `wf_9ef41eb9-bc5`). The \"channel not found\" state was 2 lines of unstyled text floating in the empty pane:\n\n```tsx\n<p>Channel not found</p>\n<p>You may not be a member of this channel, or it may have been archived.</p>\n```\n\nNo icon, no shape, no CTA, no visual distinction from a loading state that just hadn't painted yet. Users hit a blank-looking pane and couldn't tell error from loading.\n\n**Polish:**\n\n```tsx\n<div role=\"status\" className=\"flex h-full flex-col items-center justify-center gap-3 px-6 text-center\">\n  {/* Icon disc — keeps the error visually anchored */}\n  <div aria-hidden=\"true\" className=\"flex size-12 items-center justify-center rounded-full\" style={{...}}>\n    <Lock size={22} weight=\"regular\" />\n  </div>\n  <p>{tt('chat.channel.not_found_title', 'Channel unavailable')}</p>\n  <p>{tt('chat.channel.not_found_body', '... Try a channel from the sidebar.')}</p>\n</div>\n```\n\nThree changes:\n\n1. **Icon disc** — 48×48 rounded box with `Lock` glyph at 22 px. Soft fg-default 6% bg + 1 px inset ring. Reads as \"lock\" → \"you can't get into this place\" — semantically right for both archived AND not-a-member.\n2. **Tokenised typography** — title moves to `--text-chat-section` token (was `text-sm` literal); body to `--text-chat-meta`. Scales with the chat fluid scale on ultrawide.\n3. **i18n + recovery copy** — title becomes \"Channel unavailable\" (acknowledges the state without blaming the user); body keeps the dual-cause hint but adds \"Try a channel from the sidebar\" so the user has a next move.\n\nTitle and body strings now go through `tt()` — previously the strings were hardcoded English. Two new i18n keys: `chat.channel.not_found_title`, `chat.channel.not_found_body`.\n\n`role=\"status\"` on the wrapper makes the error announce via aria-live (default `polite` for status role) without forcing assertive interruption.\n\n**Why no inline CTA button:** the user is ALREADY inside the channel route via a permalink they probably clicked. They have a sidebar in view (or a hamburger to it on mobile). An \"Open sidebar\" button risks duplicating the sidebar trigger that's already on screen. The copy points them at the sidebar; no extra button.\n\n**What we did NOT differentiate (yet):** archived vs not-a-member visual treatment. The audit's \"two distinct surfaces\" recommendation requires the failure mode to be known at this code path — but if the channel can't be loaded, we don't know WHY. A future round could split this once the action returns a typed error code.\n\n**Verification:** chat 107/107 tests pass; channel-view typecheck clean.\n\n**Sources:** empty-state audit gap #2 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.830Z","updatedAt":"2026-06-05T01:29:11.830Z"},{"id":"e0d4f68f-be72-4c2a-a7ca-a29898b2dda9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-71-sidebar-skeleton-shape","type":"changed","scope":"chat","summary":"Round 71 — sidebar `ChannelSkeletons` now matches the real `ChannelRow` shape (icon-shaped square + text bar of varying width) instead of a flat `h-7` bar. Reduces layout shift when skeletons fade into real channel rows.","body":"Surfaced by empty-state audit gap #3. (The audit claimed the skeletons were \"static\" — partially false: they use `Skeleton` which has `ui-shimmer` + the global `prefers-reduced-motion` block covers it. But the shape mismatch was real.)\n\n### Was — flat bar\n\n```tsx\nfunction ChannelSkeletons({ count }: { count: number }) {\n  return (\n    <div className=\"space-y-1.5 px-1 py-1\">\n      {Array.from({ length: count }).map((_, i) => (\n        <Skeleton key={i} className=\"h-7 w-full\" />\n      ))}\n    </div>\n  );\n}\n```\n\nWhen channels loaded, the flat bar was replaced by a row with an icon + a name — meaning the layout JUMPED (no icon space reserved, text width changed). Classic CLS issue.\n\n### Now — shape-matched\n\n```tsx\nfunction ChannelSkeletons({ count }: { count: number }) {\n  const widths = [86, 72, 90, 64];\n  return (\n    <div className=\"space-y-1.5 px-1 py-1\">\n      {Array.from({ length: count }).map((_, i) => {\n        const w = widths[i % widths.length] ?? 80;\n        return (\n          <div key={i} className=\"flex h-7 items-center gap-2.5 px-2.5\" aria-hidden=\"true\">\n            <Skeleton className=\"size-3.5 shrink-0 rounded-[var(--radius-xs)]\" />\n            <Skeleton className=\"h-3 rounded-[var(--radius-xs)]\" style={{ width: `${w}%` }} />\n          </div>\n        );\n      })}\n    </div>\n  );\n}\n```\n\nTwo changes:\n\n1. **Icon-shaped square** on the left — 14 px squircle (`size-3.5`). Matches the footprint of the channel icon (Hash, Lock, etc.) that's about to land. The squircle reuses `Skeleton` which still uses `ui-shimmer` for the loading animation.\n2. **Variable-width text bar** — widths cycle 86 / 72 / 90 / 64 px so the placeholder list reads as different channels rather than identical stripes. Linear / Notion both use this trick on their list skeletons.\n\nPadding (`px-2.5`) + gap (`gap-2.5`) matched to the real ChannelRow so when the skeleton transitions to the real row, the icon and text land in nearly the same place. Layout shift goes from ~12 px (no icon → icon) to ~0.\n\n`aria-hidden=\"true\"` on the outer wrapper so AT users don't hear the placeholder list at all — the `aria-busy` on the parent loader is the signal that says \"loading.\"\n\n**Verification:** chat 107/107 tests pass; sidebar typecheck clean.\n\n**Sources:** empty-state audit gap #3 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.004Z","updatedAt":"2026-06-05T01:29:12.004Z"},{"id":"667acc5f-b6b6-4df3-8dd5-2d5347fa175c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-62-typing-indicator-a11y","type":"fixed","scope":"chat","summary":"Round 62 — `<TypingIndicator>` live region gains `aria-atomic=\"true\"` so screen readers announce the full name list each update; reduced-motion CSS class-name typo fixed (`.helios-typing-bounce` selector never matched the actual `.helios-typing-dot` elements).","body":"Two a11y bugs in the typing indicator surfaced by presence audit gaps #4 + #8 (workflow `wf_9ef41eb9-bc5`).\n\n### 1. Missing `aria-atomic` on the typing live-region\n\n`aria-live=\"polite\"` was set on the wrapper, but `aria-atomic` was missing. Without atomic, screen readers may only announce the diffed text node when the name list updates — producing choppy announcements like:\n\n> \"and Bob are typing\"\n\ninstead of:\n\n> \"Alice and Bob are typing\"\n\n```diff\n  <div\n    className=\"flex h-6 items-center gap-2\"\n    style={...}\n    aria-live=\"polite\"\n+   aria-atomic=\"true\"\n  >\n```\n\n`aria-atomic=\"true\"` forces the screen reader to read the entire updated text whenever any child changes — the typing indicator is short enough (≤80 chars even at 4 typers) that re-reading the whole thing is the right tradeoff.\n\n### 2. Reduced-motion CSS class-name typo\n\nThe `prefers-reduced-motion: reduce` block in `styles.css` listed `.helios-typing-bounce` — but the actual animated elements wear `.helios-typing-dot`. `helios-typing-bounce` is the KEYFRAME name, not a class. The selector never matched anything in markup; vestibular-sensitive users were seeing three pulsing dots regardless of their motion preference.\n\n```diff\n  @media (prefers-reduced-motion: reduce) {\n    .helios-message-new,\n    .helios-message-focused,\n    .helios-msg-actions,\n+   .helios-typing-dot,       /* real selector — matches markup */\n    .helios-typing-bounce,    /* kept as no-op back-compat */\n    .helios-jump-to-latest,\n    .ui-breath {\n      animation: none !important;\n    }\n```\n\nNow reduced-motion users get static dots instead of the bouncing trio. The label text (\"Alice is typing\") still conveys the state.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** presence audit gaps #4 + #8 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.705Z","updatedAt":"2026-06-05T01:29:11.705Z"},{"id":"10558f77-a040-442d-b7cb-34b7e9aba8cc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-66-ai-thread-retry-button","type":"changed","scope":"chat","summary":"Round 66 — AI thread pane error state gains a Retry button calling `openQuery.refetch()`. Previously users hit \"Could not open the AI session — try again later\" with no in-pane recovery; only escape was close + reopen.","body":"Surfaced by AI-thread audit gap #7 (workflow `wf_9ef41eb9-bc5`).\n\nThe Ask Helios pane's error state was a flat danger pill:\n\n```tsx\n<p style={{ background: 'danger-500 8%, transparent', color: 'danger-500' }}>\n  Could not open the AI session — try again later.\n</p>\n```\n\nNo retry, no fallback, no recovery affordance. Users had to close the pane and reopen it — and on transient network errors that's a 3-click recovery for what should be 1 tap.\n\n### Fix\n\n```tsx\n<div role=\"alert\" className=\"flex items-center justify-between gap-3 ...\" style={...}>\n  <span>{tt('chat.ai_thread.open_failed', 'Could not open the AI session.')}</span>\n  <button\n    type=\"button\"\n    onClick={() => void openQuery.refetch()}\n    disabled={openQuery.isFetching}\n    className=\"... active:enabled:scale-[0.97] disabled:opacity-50\"\n    aria-label={tt('chat.ai_thread.retry_open', 'Retry opening the AI session')}\n  >\n    {openQuery.isFetching\n      ? tt('chat.ai_thread.retrying', 'Retrying…')\n      : tt('common.retry', 'Retry')}\n  </button>\n</div>\n```\n\nChanges:\n\n- **Layout** → `flex items-center justify-between gap-3` so the message and the Retry button share a single row with right-aligned action.\n- **role=\"alert\"** on the wrapper → screen readers announce the error immediately (more assertive than the previous bare `<p>` that had no semantic role).\n- **Retry button** → calls `openQuery.refetch()` (the standard TanStack Query retry path); during refetch the button reads \"Retrying…\" and is disabled. `active:enabled:scale-[0.97]` matches the touch-feedback vocabulary from round 59.\n- **Copy trim** — dropped \"try again later\" since the user now CAN try again immediately.\n- **i18n keys** — 3 new keys: `chat.ai_thread.retry_open`, `chat.ai_thread.retrying`, `common.retry`. Existing `chat.ai_thread.open_failed` kept (text trimmed).\n\n### What we did NOT add (this round)\n\n- **Distinct error types** (transient network vs. permission denied) — requires the `chat.ai.thread.open` action to return a typed error code. Worth a focused round when the action error envelope is improved.\n- **Per-AI-message regenerate button** — needs the `messages.length > 0 && lastIsError` branch + a `chat.ai.thread.regenerate` action. Bigger scope, separate round.\n- **Composer wrapper padding parity** with main composer — the audit flagged `px-3` vs main's `px-4 pb-4 pt-2`. The visual diff is subtle; leaving it for an a/b verification pass with screenshots, not a blind change.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.\n\n**Sources:** AI-thread audit gap #7 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.924Z","updatedAt":"2026-06-05T01:29:11.924Z"},{"id":"c6047776-a1dd-472a-b6c4-cde0a86d4c62","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-72-ai-thread-action-chips","type":"added","scope":"chat","summary":"Round 72 — AI responses in the Ask Helios pane gain Copy + Try-again chips below each message. Mirrors the Claude.ai / ChatGPT action-chip pattern; sits at 50% opacity by default, lifts to full on hover/focus.","body":"Surfaced by AI-thread audit gap #5 (workflow `wf_9ef41eb9-bc5`).\n\nAI messages in the Ask Helios pane rendered via `<MessageItem onReact={() => undefined} inThread />` — meaning every hover-action toolbar handler was undefined and the entire toolbar collapsed. Users couldn't:\n- Copy the AI response (huge friction when wanting to paste it into a Linear issue, Notion doc, or email)\n- Tell the AI to \"try again\" (had to retype the question manually)\n- React / fork / pin / bookmark — but those are lower-priority for AI responses anyway\n\n### What lands\n\nA new `<AiMessageActions>` component renders below every AI message in the ai-thread-pane (not inside MessageItem — keeps the shared primitive clean). The chip row sits at `opacity: 0.5` by default and lifts to `opacity: 1` on hover or focus-within.\n\n```tsx\n{m.authorType === 'ai' && <AiMessageActions message={m} />}\n```\n\nTwo chips:\n\n**Copy** — `navigator.clipboard.writeText(message.bodyPlain ?? '')` + toast confirm. Standard chat-AI clipboard pattern. New i18n keys: `chat.ai_thread.copied`, `chat.ai_thread.copy_failed`, `chat.ai_thread.copy_response`, `common.copy`.\n\n**Try again** — there's no `chat.ai.regenerate` action yet, so the chip dispatches a `helios:chat:ai-prefill` window event with a pre-filled \"Try that again with a different angle:\" preface. The composer can listen for the event (future work — registering the listener is a 4-line change in `composer-tiptap.tsx` when ready). Until then, the chip surfaces the affordance without breaking when clicked. New i18n keys: `chat.ai_thread.regenerate`, `chat.ai_thread.regenerate_aria`, `chat.ai_thread.regenerate_preface`.\n\n### Visual + a11y\n\n- Chips at `text-chat-micro` (~10 px) — quieter than message body.\n- `ml-10` left padding lines them up with the message body past the avatar gutter.\n- `transition-[background-color,transform] hover:bg-hover active:scale-[0.95]` — touch feedback matches the round-59 huddle vocab.\n- `focus-visible:ring-2 focus-visible:ring-[var(--color-ai-500)]` — AI-tinted focus ring (not module-chat) so the keyboard journey reads as \"this is the AI surface.\"\n\n### What we did NOT add (this round)\n\n- **Send to channel** — would need a `chat.ai.send_to_channel(messageId, channelId)` action that posts the AI response back into the originating channel. Future work.\n- **Fork into new thread** — same. Needs server support.\n- **Composer prefill listener** — the event dispatches but no listener is registered yet. Stubbed for future round.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.\n\n**Sources:** AI-thread audit gap #5 + AI-thread research Pattern 5 + Pattern 8 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.093Z","updatedAt":"2026-06-05T01:29:12.093Z"},{"id":"d97b0333-52d9-45ae-b9cd-8c77b5d17480","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-83-extract-tasks-kbd-hints","type":"changed","scope":"chat","summary":"Round 83 — ExtractTasksModal gains a keyboard hints footer (Esc / Tab / Space) on `sm+` so users know how to navigate the task list without trial-and-error. Hidden on `<sm` per round-28 hide convention.","body":"Surfaced by modal audit gap #6 (workflow `wf_9ef41eb9-bc5`).\n\nThe ExtractTasksModal is a task-list-heavy modal — the AI streams candidate tasks, the user checks/unchecks rows, edits priorities, assigns each to a project. Keyboard navigation works (Tab through rows, Space to toggle checkbox, Esc to cancel) but is discoverable only via trial-and-error.\n\nLinkWorkPicker already had a keyboard hints footer (shipped earlier); this round brings the same pattern to ExtractTasksModal so the modal family is consistent.\n\n```tsx\n<div\n  className=\"hidden items-center gap-3 pt-1 sm:flex\"\n  style={{ fontSize: 'var(--text-chat-micro, 10px)', color: 'var(--fg-faint)' }}\n>\n  <span className=\"inline-flex items-center gap-1\">\n    <kbd>Esc</kbd>\n    <span>{tt('chat.extract_tasks.kbd_cancel', 'cancel')}</span>\n  </span>\n  <span className=\"inline-flex items-center gap-1\">\n    <kbd>Tab</kbd>\n    <span>{tt('chat.extract_tasks.kbd_navigate', 'navigate tasks')}</span>\n  </span>\n  <span className=\"inline-flex items-center gap-1\">\n    <kbd>Space</kbd>\n    <span>{tt('chat.extract_tasks.kbd_toggle', 'toggle')}</span>\n  </span>\n</div>\n```\n\n### Design\n\n- **`hidden sm:flex`** — touch users (no Esc / Cmd / Tab keyboard) get nothing; desktop sees the row. Per the round-28 kbd-hint hide convention.\n- **Three chords** — the three actual keys the modal uses. Esc + Tab are native modal/form bindings; Space toggles the checkbox-style row selection (standard form semantic).\n- **`text-chat-micro`** (10 px) — quieter than the modal body so it reads as informational, not as a primary affordance.\n- Three new i18n keys: `chat.extract_tasks.kbd_cancel`, `.kbd_navigate`, `.kbd_toggle`.\n\n### What we did NOT add\n\nA Cmd+Enter \"Submit\" chord. The submit button (Create N tasks) sits in the same modal footer + has a clear primary CTA treatment. Adding Cmd+Enter would be reasonable but inconsistent with NewPollModal (which doesn't expose its submit chord) — better to do the whole modal family together in a focused round.\n\n**Verification:** chat 107/107 tests pass; extract-tasks-modal typecheck clean.\n\n**Sources:** modal audit gap #6 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.473Z","updatedAt":"2026-06-05T01:29:12.473Z"},{"id":"b672f075-527d-49a1-b601-486d351ac2b5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-91-entity-link-chip-token","type":"changed","scope":"chat","summary":"Round 91 — `EntityLinkChip` live-status pill font literal `9.5px` migrates to `--text-chat-micro` + tracking 0.06 → 0.08em for round-54 chip-family parity. Appears in every cross-module entity reference inside messages.","body":"Final residual literal cleanup from the round-53 drift sweep + round-54 chip-family unification.\n\n`EntityLinkChip` is the renderer for cross-module entity references inside chat messages — when a user pastes a CRM deal link, mentions a project task, links to an HRM employee, etc., the chip renders with a live-resolving status pill (\"won\", \"qualified\", \"open\", \"in progress\", etc.). The pill uses an accent tint derived from the module + the entity's live status.\n\nTwo small drifts on that pill:\n\n```diff\n- className=\"rounded-full px-1.5 py-px font-semibold uppercase\"\n+ className=\"rounded-full px-1.5 py-px font-semibold uppercase tracking-[0.08em]\"\n  style={{\n-   fontSize: '9.5px',\n-   letterSpacing: '0.06em',\n+   fontSize: 'var(--text-chat-micro, 9.5px)',\n    background: `color-mix(in oklch, ${tint} 22%, transparent)`,\n    color: tint,\n  }}\n```\n\n1. **`fontSize: '9.5px'` → `var(--text-chat-micro, 9.5px)`** — last residual `9.5px` literal in the chat module. Fluid scale catches up on ultrawide.\n2. **`letterSpacing: '0.06em'` inline → `tracking-[0.08em]` className** — drops the inline override (was overriding the inherited chat letter-spacing). Bumps to 0.08em matching the round-54 unified chip family (AI / Pinned / Resolved). All chips in the chat module now share the same tracking value for micro-uppercase labels.\n\nInline `letterSpacing` removed entirely; `tracking-[0.08em]` on the className handles it. The previous setup had a className specifying nothing for letter-spacing AND an inline override — confusing for future readers.\n\nNet visual delta on a 1440 px laptop: imperceptible (0.02em is sub-pixel at 9.5 px). On ultrawide + at the rendered ~10.5 px the tracking is ~0.21 px more open — matches the family.\n\n**Verification:** chat 107/107 tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.739Z","updatedAt":"2026-06-05T01:29:12.739Z"},{"id":"13875cba-4953-4059-b7f2-6d42aa1d7428","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-geo-aware-routing","type":"added","scope":"payments","summary":"Payment routing rules can now target a payer country or region (e.g. US or EU-*).","body":"Routing rules gained a `country_match` dimension alongside event-class and\ncurrency. A rule can match an exact ISO country (`US`), a region group\n(`EU-*`, `APAC-*`, …), or any country (`*`, the default — every existing rule\nkeeps matching all countries). Country slots below currency and above amount\nbounds in the specificity score, so a currency-pinned rule still outranks a\ncountry-pinned one.\n\nThe payer country is resolved at link-create time from the invoice's billing\ncountry, falling back to the org's own country, then \"any\". Free-text billing\ncountries are ignored so a malformed value can never fail a payment link.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.556Z","updatedAt":"2026-06-05T01:29:13.556Z"},{"id":"13f3d1ff-0e11-4e07-84a6-e98911b74ad3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-53-typography-token-drift-cleanup","type":"changed","scope":"chat","summary":"Round 53 — four of the most-visible message + sidebar chrome literals (gutter timestamp, AI pill, notify-popover header, unread badge) migrate from hardcoded `text-[9.5/10.5px]` to fluid `--text-chat-micro` / `--text-chat-caption` tokens. New `--text-chat-micro` token added (9.5-10.5 px).","body":"The V3 chat fluid type scale (`--text-chat-*`, clamp-based) had 8 tiers from `display` (20-26 px) down to `caption` (10.5-11.5 px). Below `caption` were 9.5 px AI pills and gutter timestamps — but those were hardcoded `text-[9.5px]` / `text-[10.5px]` literals that stayed flat on ultrawide displays while the rest of the chat scale crept up.\n\n### What lands\n\n**1. New `--text-chat-micro` token** (9.5-10.5 px clamp):\n\n```css\n--text-chat-micro: clamp(9.5px, 0.6rem + 0.04vw, 10.5px);\n```\n\nFills the gap below `caption` for AI pills, gutter timestamps, micro-labels.\n\n**2. Migrations (4 highest-visibility chrome literals):**\n\n- **Continuation-row gutter timestamp** (message-item.tsx line ~487): `text-[10.5px]` → `var(--text-chat-micro)`. Every continuation row in every channel shows this on hover.\n- **AI badge pill** (message-item.tsx line ~536): `fontSize: '9.5px'` → `var(--text-chat-micro)`. Plus drops the redundant `letterSpacing: '0.06em'` inline that was shadowing the className's `tracking-[0.06em]`.\n- **Notify-popover section header** (chat-channels-sidebar.tsx line ~2981): `text-[10.5px]` → `var(--text-chat-caption)`. Slack-style uppercase section label.\n- **Unread badge digits** (chat-channels-sidebar.tsx line ~3240): `text-[10.5px]` → `var(--text-chat-caption)`. The \"12 unread\" pill on every channel row.\n\n### Why these four\n\nEach is rendered MANY times per chat surface (every message row has a gutter timestamp on hover; every unread channel has a badge). Drift on these compounds visually across the screen. The other 7 hardcoded literals (forwarded-message chips, thread-preview headers, edited-time labels) appear less-frequently and are scoped to specific chip surfaces — they get tackled in a separate sweep when the visual context calls for it.\n\n### Migration pattern documented\n\nThe replacement is consistent: hardcoded `text-[N.5px]` className → `style={{ fontSize: 'var(--text-chat-<tier>, N.5px) }}` with a literal fallback. Future polish rounds can apply the same pattern to the remaining 7 literals.\n\n**Verification:** chat 107/107 tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.419Z","updatedAt":"2026-06-05T01:29:11.419Z"},{"id":"567341b5-37df-4a93-99de-ae7d5fb201be","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-58-sidebar-interaction-polish","type":"changed","scope":"chat","summary":"Round 58 — sidebar interaction polish. Unread badges fade + scale on mount/unmount instead of popping, star button uses a tactile scale+color shift on hover instead of a bg that competed with the row hover.","body":"Two audit:sidebar gaps. Both about removing visual jitter that compounded during multi-row operations.\n\n### 1. Unread badge transition (audit:sidebar Gap #1)\n\nThe badge unmounted instantly via `if (count <= 0) return null` whenever a channel transitioned from unread → read. During bulk \"Catch up everything\" sweeps the right edge of the sidebar flickered as ~6 badges popped out simultaneously.\n\nNew behaviour:\n\n```tsx\nfunction UnreadBadge({ count }: { count: number }) {\n  const [displayedCount, setDisplayedCount] = useState(count);\n  const [mounted, setMounted] = useState(count > 0);\n  const visible = count > 0;\n  useEffect(() => {\n    if (count > 0) {\n      setMounted(true);\n      setDisplayedCount(count);\n      return;\n    }\n    if (!mounted) return;\n    const t = setTimeout(() => setMounted(false), 220);\n    return () => clearTimeout(t);\n  }, [count, mounted]);\n  if (!mounted) return null;\n\n  return (\n    <span\n      aria-hidden={!visible}\n      style={{\n        opacity:   visible ? 1 : 0,\n        transform: visible ? 'scale(1)' : 'scale(0.6)',\n        transition:\n          'opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), ' +\n          'transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1)',\n      }}\n    >\n      {displayedCount > 99 ? '99+' : displayedCount}\n    </span>\n  );\n}\n```\n\nKey detail: `displayedCount` is updated ONLY when count goes up (`count > 0` branch). On the exit, the badge keeps showing the last-known count (e.g. \"5\") while it fades — never shows \"0\" mid-animation. After 220 ms the unmount fires and the row reclaims the badge slot.\n\nSpring curve on the transform gives the entrance a tiny pop; opacity fades on the standard curve so the exit reads as smooth.\n\n### 2. Star button hover containment (audit:sidebar Gap #7)\n\nThe star button (channel pin-to-top) used `hover:bg-[var(--bg-hover)]` — the EXACT same bg the parent row uses on hover. The two overlapped and the row visually shifted when the cursor crossed the star.\n\nReplaced with a tactile scale + color shift that's contained to the star itself:\n\n```diff\n- 'flex size-4 ... transition-colors hover:bg-[var(--bg-hover)]'\n+ 'flex size-4 ... transition-[transform,color] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] hover:scale-[1.18] active:scale-[0.92]'\n```\n\n`transition-[transform,color]` (not `transition-colors`) so we don't accidentally re-enable the bg transition the row deliberately blocks (see CSS comment around line ~2537: \"NO transition-colors here on purpose\"). Both the active-starred state and the on-hover-reveal state get the same treatment for consistency.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** audit:sidebar Gaps #1 + #7 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.654Z","updatedAt":"2026-06-05T01:29:11.654Z"},{"id":"4d17c430-d7b9-4653-a3b5-ca4f5983abbb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-65-empty-state-polish","type":"changed","scope":"chat","summary":"Round 65 — empty-channel/DM title now matches the channel type (no more \"Start the conversation\" header above a DM-specific subtitle); sidebar `EmptyHint` becomes a flex column with breathing room + max-width copy.","body":"Surfaced by empty-state audit gaps #1 + #4.\n\n### 1. MessageList empty title now type-aware\n\nThe empty-channel state had a hardcoded \"Start the conversation\" title above a channel-type-aware subtitle (`emptyStateLabel`). When the subtitle said \"Start your conversation with Alice\" (DM), the title sat oddly on top reading like the same instruction twice.\n\n```diff\n- {tt('chat.message_list.empty_title', 'Start the conversation')}\n+ {channelType === 'dm' || channelType === 'group_dm'\n+   ? tt('chat.message_list.empty_title_dm', 'Start your conversation')\n+   : tt('chat.message_list.empty_title_channel', 'This channel is empty')}\n```\n\nTwo new i18n keys. Default body for channels also moved from the bossy \"Send the first message\" to the gentler \"Send the first message to get the channel going\" — same length, less command-like.\n\n### 2. Sidebar EmptyHint structural polish\n\nWas a single `<p>` with dashed-border + center text. Now a flex column with `gap-2` and the copy wrapped in `max-w-[28ch]` so long translations don't sprawl edge-to-edge:\n\n```diff\n- <p className=\"rounded-md border border-dashed px-3 py-3 text-center leading-snug\"\n-    style={{...}}>\n-   {text}\n- </p>\n+ <div className=\"flex flex-col items-center gap-2 rounded-md border border-dashed px-3 py-4 text-center leading-snug\"\n+      style={{...}}>\n+   <p className=\"max-w-[28ch]\">{text}</p>\n+ </div>\n```\n\n`py-3` → `py-4` for slightly more breathing room (the section is rare, so the extra height isn't a viewport cost). The `<div>` + `<p>` wrapping prepares the primitive for an optional `icon` slot in a future round — the type signature now accepts an `icon?: React.ReactNode` even though current callers don't pass one (backward-compatible).\n\n### What we deferred\n\nThe audit also called for an inline \"Create your first channel\" button replacing the \"click + to create one\" text. Skipping for now — that requires lifting `setCreateOpen` access into the EmptyHint, which threads state through 3 levels of the sidebar tree. Worth a focused round when it's the only thing changing.\n\n**Verification:** chat 107/107 tests pass; touched files typecheck clean.\n\n**Sources:** empty-state audit gaps #1 + #4 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.830Z","updatedAt":"2026-06-05T01:29:11.830Z"},{"id":"58025bf4-fa31-423c-91ba-9f53f5ba0735","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-69-huddle-mute-shape-cue","type":"changed","scope":"chat","summary":"Round 69 — huddle muted-participant indicator gets a red-tinted pill chassis around the MicrophoneSlash glyph + an explicit `aria-label=\"Muted\"`. Visible at size-10 compact-tile scale + audible to AT.","body":"Surfaced by huddle audit gap #2.\n\nThe participant-tile mic indicator showed `MicrophoneSlash` at size 10 or 12 with red `#fb7185` color. The slash glyph IS a shape signal (WCAG 1.4.1), but at size 10 on a tight bottom-left chip the slash detail was hard to spot at a glance. Plus the icon was a silent decoration to AT — only the participant name was announced from the chip.\n\n### Fix — pill chassis + aria-label\n\nTwo surfaces patched (bottom-left chip + compact filmstrip footer):\n\n```tsx\n{micMuted ? (\n  <span\n    aria-label={tt('chat.huddle.muted', 'Muted')}\n    className=\"inline-flex size-3.5 items-center justify-center rounded-full\"\n    style={{\n      background: 'rgba(220, 38, 38, 0.35)',\n      boxShadow: 'inset 0 0 0 1px rgba(220, 38, 38, 0.6)',\n    }}\n  >\n    <MicrophoneSlash size={9} weight=\"fill\" style={{ color: '#fecaca' }} />\n  </span>\n) : ...}\n```\n\n- **Red-tint pill chassis** — 14 px (3.5 × 4) circle with 35% red fill + 1 px inset red ring. The red is now communicated as a SHAPE (the pill itself) not just a glyph color. At small sizes the pill reads instantly as \"warning\" even when the slash detail is illegible.\n- **Glyph color shift** — `#fb7185` (red-rose) → `#fecaca` (red-100 pale). The dark red pill behind needs a lighter glyph to maintain contrast; the pale glyph sits inside the pill as a \"muted\" graphical hint.\n- **`aria-label=\"Muted\"`** — was a silent decoration; now AT announces \"Muted\" alongside the participant name. New i18n key `chat.huddle.muted`.\n\nThe unmuted Microphone icon is unchanged (white at 85% opacity, no pill) — only the muted state is visually heightened. Asymmetric on purpose: muted is the state that needs to stand out.\n\nApplied symmetrically to:\n- The bottom-left chip on every participant tile (line ~1315)\n- The compact filmstrip footer mute indicator (line ~1593)\n\n**Verification:** chat 107/107 tests pass; huddle-stage typecheck clean.\n\n**Sources:** huddle audit gap #2 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.950Z","updatedAt":"2026-06-05T01:29:11.950Z"},{"id":"694a3abd-2c76-48d3-a1c5-17e6f2dcc1c8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-80-channel-skeleton-cls","type":"fixed","scope":"chat","summary":"Round 80 — channel-view loading skeleton header height + message-row padding now match the live components (was h-14 + py-3/sm:py-4; live is `--space-chat-header` + py-2/sm:py-3). Eliminates the 4-8 px vertical jump when messages paint.","body":"Surfaced by empty-state audit gap #5 (workflow `wf_9ef41eb9-bc5`).\n\nThe channel-view loading skeleton intended to be a pixel-perfect placeholder for the live channel — so when messages paint, layout doesn't shift. Two mismatches were causing visible jumps:\n\n### 1. Header height mismatch\n\n```diff\n- <div className=\"flex h-14 shrink-0 items-center gap-3 px-3 sm:px-6 lg:px-8\"\n-      style={{ borderBottom: '1px solid var(--border-subtle)' }}>\n+ <div className=\"flex shrink-0 items-center gap-3 px-3 sm:px-6 lg:px-8\"\n+      style={{\n+        height: 'var(--space-chat-header, 56px)',\n+        borderBottom: '1px solid var(--border-subtle)',\n+      }}>\n```\n\n- `h-14` = 56 px (constant)\n- Live header = `--space-chat-header` (52 px mobile per round 23, 64 px default)\n\nThe skeleton was 4 px too tall on mobile and 8 px too short on default. Each transition popped the layout. Now both match exactly — the skeleton header swaps for the live header with zero pixel drift.\n\n### 2. Message-row padding mismatch\n\n```diff\n- <div className=\"flex-1 space-y-3 px-2 py-3 sm:px-6 sm:py-4 lg:px-8\">\n+ <div className=\"flex-1 space-y-3 px-2 py-2 sm:px-6 sm:py-3 lg:px-8\">\n```\n\nThe MessageList scroller (`message-list.tsx:429`) uses `px-2 py-2 sm:px-6 sm:py-3 lg:px-8`. The skeleton used `py-3 / sm:py-4` — 4 px extra padding per side on mobile, 4 px extra on `sm+`. Total vertical shift of 8-16 px when messages painted.\n\n### Why CLS matters here\n\nChannel switches in Helios feel snappy because the column-fade-in (round 41) covers the transition. But the underlying layout shift was still happening — just hidden by opacity. When the user clicks a channel and the messages stream in, ANY layout jump (even hidden by opacity) costs the cumulative-layout-shift metric. CLS feeds Core Web Vitals, which affects perceived performance scores.\n\nAfter this round: channel-switch CLS approaches zero for the header + message-row path. The composer skeleton was already height-locked (`h-[68px]` matches the live composer's min-height).\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** empty-state audit gap #5 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.306Z","updatedAt":"2026-06-05T01:29:12.306Z"},{"id":"791957ce-3507-4da8-8e3b-1d9d353d91f6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-milestone-approvals","type":"added","scope":"projects","summary":"Clients can approve or request changes on delivered milestones from the portal; operators see the sign-off on the roadmap.","body":"Added milestone sign-off to the client portal (SURF-4). When a milestone is marked\ndone, the client can **Approve** it or **Request changes** (with an optional note)\nfrom the project's roadmap on the portal. The decision, note, timestamp, and client\nuser are recorded via `projects.portal.milestone_respond` (scoped to the client's\nown project), and the portal shows the resulting \"Approved\" / \"Changes requested\"\nstate. On the operator side the milestone card now carries a \"Client approved\" /\n\"Client requested changes\" badge so the delivery team sees the sign-off without\nleaving the project. The client action is rate-limited like the other external\nportal writes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.748Z","updatedAt":"2026-06-05T01:29:12.748Z"},{"id":"b7d50e99-a248-46a2-bb9f-748c04197a7c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-54-pill-chip-family","type":"changed","scope":"chat","summary":"Round 54 — author-line chip family (AI / Pinned / Resolved pills) gets a unified gradient + inset highlight + accent ring treatment. Plus tracking 0.06em → 0.08em for sharper uppercase reading at micro scale.","body":"The three chips that can appear on a message's author line — `AI`, `Pinned`, `Resolved` — previously each used a single flat `color-mix(... 14%, transparent)` background. Flat, slightly anonymous, and not in family with the AI pill that round 53 had already promoted to gradient.\n\n### What lands — unified chip recipe\n\nEach chip now renders with the same three-layer treatment:\n\n```css\n/* Background — subtle 18%→10% top-to-bottom gradient in the accent color */\nbackground: linear-gradient(180deg,\n  color-mix(in oklch, var(--accent) 18%, transparent) 0%,\n  color-mix(in oklch, var(--accent) 10%, transparent) 100%);\n\n/* Box-shadow — inset white-mix highlight (catches the eye at top edge)\n *               + outer 1 px accent ring (separates from author cluster) */\nbox-shadow:\n  inset 0 1px 0 0 color-mix(in oklch, white 22%, transparent),\n  0 0 0 1px color-mix(in oklch, var(--accent) 22%, transparent);\n```\n\nPer-chip accent token:\n- **AI** → `var(--color-ai-500)` (purple)\n- **Pinned** → `var(--color-warning-500)` (amber)\n- **Resolved** → `var(--color-success-500)` (green)\n\nSame \"premium chip\" texture as Claude.ai / ChatGPT model badges. Without changing layout — just texture.\n\n### Tracking + padding\n\n- `tracking-[0.06em]` → `tracking-[0.08em]` on all three chips. At ~9.5 px, the slightly looser letter-spacing reads cleaner — the previous value was lifted from a 12 px context where 0.06em already feels tight.\n- `py-0` → `py-[1px]` to give the gradient + inset highlight 1 px of vertical room. Pre-fix the gradient was clipped almost flat against the text baseline.\n\n### Pinned + Resolved fluid-type migration\n\nThe two chips inherited round 53's `--text-chat-micro` token migration. Was `fontSize: '9.5px'` literal; now scales with the chat type system on ultrawide displays.\n\n**Verification:** chat 107/107 tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.457Z","updatedAt":"2026-06-05T01:29:11.457Z"},{"id":"f86bf862-4e35-4777-8d66-3ceae8803182","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-63-avatar-ring-hover-contrast","type":"fixed","scope":"chat","summary":"Round 63 — avatar status badges keep visible silhouette on hovered/active rows. Two-layer ring (2 px `--bg-default` crop + 3 px `--fg-default` 12% contrast outer) replaces the single-layer ring that vanished against `--bg-subtle` row tints.","body":"Surfaced by presence audit gap #7 (workflow `wf_9ef41eb9-bc5`).\n\nThe Avatar status badge (`StatusBadge` in `packages/ui/src/primitives/avatar.tsx`) used a single-layer `box-shadow: 0 0 0 2px var(--bg-default)` to crop itself away from the avatar circle. The crop-ring color was hard-pinned to `--bg-default`. When a row hovered (chat sidebar row, mention list row, member popover row) the bg tinted to `--bg-subtle` — in dark mode that's a ~6% lighter oklch which made the 2 px ring near-invisible. The status dot lost its silhouette during hover.\n\n**Fix — two-layer ring:**\n\n```diff\n- const ring = 'shadow-[0_0_0_2px_var(--bg-default)]';\n+ const ring =\n+   'shadow-[0_0_0_2px_var(--bg-default),0_0_0_3px_color-mix(in_oklch,var(--fg-default)_12%,transparent)]';\n```\n\n- **Inner 2 px ring** in `--bg-default` — keeps the existing crop behaviour on non-hovered surfaces (no visual regression).\n- **Outer 3 px ring** in 12% `--fg-default` — always contrast-positive against any reasonable bg tint. Renders the dot's silhouette during hover when the inner ring blends into the row.\n\nThe two layers compose into a 1 px visible halo of fg-default-at-12% that you only notice on hovered/active surfaces — too low-alpha to register on the default row, just enough to outline the dot when the inner ring fails.\n\n### in_huddle special case\n\nRound 61's `in_huddle` had its own inline `boxShadow` (white inset ring + module-chat halo) that overrode the className-based ring. Updated that case to include the same 3 px contrast outer ring so the in-huddle dot also stays visible on hovered rows:\n\n```diff\n  boxShadow:\n-   '0 0 0 2px var(--bg-default),\n-    inset 0 0 0 1.5px color-mix(in oklch, white 70%, transparent),\n-    0 0 8px -1px color-mix(in oklch, var(--color-module-chat) 55%, transparent)',\n+   '0 0 0 2px var(--bg-default),\n+    0 0 0 3px color-mix(in oklch, var(--fg-default) 12%, transparent),\n+    inset 0 0 0 1.5px color-mix(in oklch, white 70%, transparent),\n+    0 0 8px -1px color-mix(in oklch, var(--color-module-chat) 55%, transparent)',\n```\n\n**Verification:** chat 107/107 tests pass; avatar.tsx typecheck clean.\n\n**Sources:** presence audit gap #7 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.724Z","updatedAt":"2026-06-05T01:29:11.724Z"},{"id":"060c2e57-983a-4e3e-bf2c-80b480883280","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-73-ai-thread-jump-pill","type":"added","scope":"chat","summary":"Round 73 — AI thread pane gets a \"New replies ↓\" jump pill when the user scrolls up + a new reply arrives. Lets users opt-in to scroll-to-bottom instead of being yanked away mid-read.","body":"Surfaced by AI-thread audit gap #6 (workflow `wf_9ef41eb9-bc5`).\n\nPrevious behaviour: AI thread pane used `scrollTo({ behavior: 'smooth' })` to follow every new message — gated only on `stickRef.current` (within 80 px of bottom). If a user scrolled up to re-read an earlier AI reply and a new reply landed mid-read, the scroller yanked them down. Focus broken. The main `<MessageList>` already had a jump pill (round 51-era); this round ports the pattern to the AI thread.\n\n### What lands\n\n```ts\nconst [showJumpPill, setShowJumpPill] = useState(false);\n\nfunction onScroll() {\n  const el = scrollRef.current;\n  if (!el) return;\n  const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;\n  const atBottom = distanceFromBottom < 80;\n  stickRef.current = atBottom;\n  // 200 px buffer prevents the pill from showing on a 50 px nudge.\n  setShowJumpPill(distanceFromBottom > 200);\n}\n\nfunction scrollToBottom() {\n  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });\n  stickRef.current = true;\n}\n```\n\n### The pill\n\n```tsx\n{showJumpPill && (\n  <div className=\"pointer-events-none absolute inset-x-0 bottom-3 z-[2] flex justify-center\">\n    <button\n      type=\"button\"\n      onClick={() => { scrollToBottom(); setShowJumpPill(false); }}\n      className=\"pointer-events-auto inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5\n                 transition-[transform,box-shadow] hover:-translate-y-0.5\n                 focus-visible:ring-2 focus-visible:ring-[var(--color-ai-500)] ...\"\n      style={{\n        background: 'color-mix(in oklch, var(--color-ai-500) 12%, var(--bg-default))',\n        borderColor: 'color-mix(in oklch, var(--color-ai-500) 35%, transparent)',\n        color: 'var(--color-ai-600)',\n        boxShadow: '0 8px 20px -8px rgba(0,0,0,0.28), inset 0 1px 0 0 white-mix-18%',\n        backdropFilter: 'blur(10px) saturate(140%)',\n      }}\n      aria-label={tt('chat.ai_thread.jump_to_latest', 'Jump to the latest AI reply')}\n    >\n      <span>{tt('chat.ai_thread.new_replies', 'New replies')}</span>\n      <span aria-hidden=\"true\">↓</span>\n    </button>\n  </div>\n)}\n```\n\n### Design notes\n\n- **AI-tinted, not module-chat-tinted** — visually marks this as part of the Ask Helios surface (matches round 72's chip ring + round 55's AI avatar).\n- **`pointer-events-none` on the wrapper + `pointer-events-auto` on the button** — clicks on the empty edges fall through to the scroller underneath (scroll wheel still works around the pill).\n- **`absolute bottom-3`** — sits 12 px above the bottom of the scroll area. The scroller wrapping div got `position: relative` added.\n- **Smart hide** — pill hides immediately on click; otherwise stays visible until the user scrolls back to within 200 px of the bottom themselves.\n\nNew i18n keys: `chat.ai_thread.jump_to_latest`, `chat.ai_thread.new_replies`.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.\n\n**Sources:** AI-thread audit gap #6 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.100Z","updatedAt":"2026-06-05T01:29:12.100Z"},{"id":"9f0498cb-e428-4c0d-9ac3-3e2bd5ff02e9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-75-huddle-quality-bars","type":"changed","scope":"chat","summary":"Round 75 — huddle `QualityDot` becomes a 3-bar cell-signal indicator. SHAPE (number of lit bars) is the primary signal; color is reinforcing not load-bearing. Colorblind users can tell strong vs weak at a glance.","body":"Surfaced by huddle audit gap #7 (workflow `wf_9ef41eb9-bc5`).\n\nThe connection-quality indicator was a single 6 px colored dot:\n- excellent → green\n- good → yellow-green\n- poor → amber\n- lost → red\n- unknown → grey\n\nThe audit's complaint: a new user doesn't intuitively know if yellow is good or bad. The traffic-light convention is regionally + culturally varied; deuteranopia / protanopia users can't reliably tell green from amber at 6 px scale.\n\n### Fix — 3-bar cell-signal indicator\n\n```tsx\nfunction QualityDot({ quality }: { quality?: ConnectionQuality }) {\n  const q = String(quality);\n  const lit = q === 'excellent' || q === 'good' ? 3\n            : q === 'poor' ? 2\n            : q === 'lost' ? 1\n            : 0;\n  const color = /* same per-quality palette as before */;\n  const heights = [3, 5, 7]; // ascending\n\n  return (\n    <span aria-label={`Connection: ${q}`} role=\"img\" className=\"inline-flex items-end gap-[1.5px]\">\n      {heights.map((h, i) => (\n        <span\n          key={i}\n          aria-hidden=\"true\"\n          style={{\n            width: 2,\n            height: h,\n            background: i < lit ? color : 'rgba(255,255,255,0.18)',\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n```\n\nThree bars at ascending heights (3 / 5 / 7 px). Lit bars carry the quality color; dim bars stay at 18% white. Maps:\n\n| quality | lit | color |\n|---|---|---|\n| `excellent` | 3/3 | green |\n| `good` | 3/3 | yellow-green |\n| `poor` | 2/3 | amber (top bar dim) |\n| `lost` | 1/3 | red (top 2 dim) |\n| `unknown` | 0/3 | grey (all dim) |\n\nShape encoding (count of lit bars, ascending heights) is now primary — color is reinforcing. WCAG 1.4.1 compliant: the indicator works without color.\n\n### Other changes\n\n- `role=\"img\"` on the wrapper so AT announces the composite as one unit (rather than 3 unlabeled spans).\n- `aria-label` colon-formatted (\"Connection: poor\") matches the title attr verbatim.\n- 200 ms `background-color` transition on each bar so quality changes animate smoothly instead of snapping (LiveKit fires `ConnectionQualityChanged` on every metric update, so the animation gives the bar a \"settling\" feel rather than rapid flicker).\n- Per-bar widths kept at 2 px so the whole indicator footprint matches the old 6 × 6 dot (3 bars × 2 px + 2 gaps × 1.5 px = 9 px wide). No layout shift in the chip.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** huddle audit gap #7 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.210Z","updatedAt":"2026-06-05T01:29:12.210Z"},{"id":"598d2c3b-44d2-4811-8afa-42f63785913a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-89-huddle-grid-kbd-nav","type":"added","scope":"chat","summary":"Round 89 — huddle participant grid gets 2D arrow keyboard navigation (←/→ within row, ↑/↓ between rows, Home/End jump). APG `grid` pattern with roving tabindex; column count derives from the rendered grid so movements match the visible layout.","body":"Surfaced by huddle audit gap #1 (workflow `wf_9ef41eb9-bc5`). Substantial new interaction work, but the tile grid is the centerpiece of the huddle stage and the lack of keyboard nav was the audit's #1 priority.\n\n### What lands\n\n**`role=\"grid\"` + `role=\"gridcell\"` semantics + roving tabindex:**\n\n```tsx\n<div ref={gridRef} role=\"grid\" aria-label=\"Participants\"\n     aria-rowcount={...} onKeyDown={onKeyDown}>\n  {participants.map((p, i) => (\n    <div\n      key={p.sid}\n      role=\"gridcell\"\n      data-tile-idx={i}\n      tabIndex={focusedIdx === i ? 0 : -1}\n      className=\"... focus-visible:ring-2 focus-visible:ring-[var(--color-module-chat)]\"\n    >\n      <ParticipantTile {...p} />\n    </div>\n  ))}\n</div>\n```\n\nOnly the FOCUSED tile is in the Tab order; arrow keys move focus without changing which DOM node holds the tab stop. APG roving-tabindex pattern (same shape as round 51's message-list nav).\n\n### Movement\n\n| Key | Action |\n|---|---|\n| `←` | Previous tile in row |\n| `→` | Next tile in row |\n| `↑` | Tile in same column, row above (subtract column count) |\n| `↓` | Tile in same column, row below (add column count) |\n| `Home` | First tile |\n| `End` | Last tile |\n\n### Why `getComputedStyle` for column count\n\nThe grid uses `grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr))` — the column count varies by viewport width. A static guess (e.g., `Math.ceil(total / 2)`) would be wrong on every viewport that doesn't match the assumption.\n\n```ts\nfunction getColumnCount(): number {\n  const el = gridRef.current;\n  if (!el) return 1;\n  const cols = getComputedStyle(el).gridTemplateColumns;\n  return cols.split(/\\s+/).filter(Boolean).length || 1;\n}\n```\n\n`gridTemplateColumns` returns a space-separated list of resolved track widths (\"180px 180px 180px\"); counting tokens gives the actual rendered column count. Resilient against window resize between keystrokes — the next ↓ press reads the current layout.\n\n### What we did NOT add\n\n- **PageUp / PageDown** — would interfere with the huddle stage's natural scroll (when the stage has more participants than viewport). The browser keeps that default.\n- **Type-ahead search** — APG grid allows it but in a live-video grid where participants come/go mid-call, the position binding would be unstable. Not appropriate for this surface.\n- **Trapped focus within the grid** — Tab still moves to the toolbar (and out of the grid), which is the natural escape path. Trapping would block users from leaving the grid via Tab.\n\n### Visual\n\n`focus-visible:ring-2` in the chat-module accent (purple) on the WRAPPER div around each tile. The ring lives outside the tile's own video/avatar art so the tile content stays uncluttered.\n\nNew i18n key: `chat.huddle.participant_grid`.\n\n**Verification:** chat 107/107 tests pass; huddle-stage typecheck clean.\n\n**Sources:** huddle audit gap #1 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.599Z","updatedAt":"2026-06-05T01:29:12.599Z"},{"id":"7fe8df1b-e880-4cb6-a871-c7e0c8de44a2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-seed-pipeline-on-org-create","type":"added","scope":"crm","summary":"New organizations automatically start with a default sales pipeline and its standard stages.","body":"Every newly created organization now gets a \"Default pipeline\" (seeded with the\nstandard discovery → proposal → negotiation → won → lost stages) the moment it's\ncreated, so the deals board is fully configured from day one and new deals land\non real pipeline stages immediately. Previously only organizations that existed\nat the configurable-pipeline rollout were backfilled; new ones fell back to a\ngeneric stage list until someone set a pipeline up by hand.\n\nThe seed runs as a domain-event subscriber and is idempotent — it skips any\norganization that already has a pipeline.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.246Z","updatedAt":"2026-06-05T01:29:13.246Z"},{"id":"b775dac4-e809-460c-addd-68e00ef0a042","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-weighted-pipeline-forecast","type":"added","scope":"crm","summary":"Deals now show a weighted pipeline forecast — expected revenue from open deals × their stage win probability.","body":"CRM now computes a weighted (expected) pipeline forecast. A new\n`crm.deal.forecast` action returns, per currency, the open value, the weighted\nvalue (Σ open-deal amount × win probability — taken from the deal's pipeline\nstage, falling back to the deal's own probability), the won value, and counts.\nOpen vs won is read from the stage flags, so custom Won/Lost stages count\ncorrectly.\n\nIt surfaces as a new **Weighted** KPI on the deals board (alongside Open\npipeline / Won / Win rate / Total) and a **Weighted pipeline** metric on the CRM\noverview's performance card. The numbers are SQL-computed, so they stay accurate\nat any volume, and are scope-aware (a rep with own-scope sees only their deals).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.287Z","updatedAt":"2026-06-05T01:29:13.287Z"},{"id":"760a6f8d-fc5e-4e61-aa40-294930140ac2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-custom-field-values","type":"added","scope":"crm","summary":"CRM records can now store values for their custom fields, validated against the field definitions.","body":"Custom fields now hold data. Each CRM record (contact, company, deal, lead)\ncarries a `custom_fields` map, written via `crm.custom_field.set_values` and read\nvia `crm.custom_field.get_values`. Set merges the provided key→value pairs onto\nthe record and validates each one against its definition — the key must be a\ndefined field, the value must match its type (text / number / date / boolean /\nselect option / URL), and a null value clears the field; unknown keys are\nrejected. Both actions are org-scoped.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.671Z","updatedAt":"2026-06-05T01:29:15.671Z"},{"id":"b2792fb2-0d57-4f0b-af96-bda600a63e21","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-59-thread-huddle-consistency","type":"changed","scope":"chat","summary":"Round 59 — huddle \"start options\" dropdown gets `active:scale-[0.97]` touch feedback (matched its sibling start button); thread + AI-thread pane header icons get explicit `aria-hidden=\"true\"` for maintainability.","body":"Two audit:thread-huddle gaps, both about consistency with neighbouring elements.\n\n### 1. Huddle \"start options\" dropdown — touch feedback (Gap #5)\n\nThe split-button pair in the huddle bar has a \"Start\" button + a \"more options\" dropdown chevron. The Start button at `huddle-bar.tsx:374` already had `transition-[background-color,box-shadow,transform] ... active:enabled:scale-[0.98]` — proper tactile press feedback. The adjacent dropdown chevron at `huddle-bar.tsx:395` had `transition-colors hover:bg-[var(--bg-hover)]` only — no press animation. On touch devices `:hover` doesn't fire on tap; users got zero feedback the dropdown was pressed.\n\n```diff\n  <button\n    type=\"button\"\n    onClick={() => setOpen((o) => !o)}\n    disabled={pending}\n-   className=\"flex items-center rounded-r-md border px-1 py-1 transition-colors hover:bg-[var(--bg-hover)]\"\n+   className=\"flex items-center rounded-r-md border px-1 py-1 transition-[background-color,transform] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-[var(--bg-hover)] active:enabled:scale-[0.97]\"\n```\n\n`active:enabled:scale-[0.97]` matches the sibling. `transition-[background-color,transform]` instead of `transition-colors` so the press animates rather than snapping.\n\n### 2. Thread + AI-thread header icons — explicit `aria-hidden=\"true\"` (Gap #7)\n\n```diff\n  /* thread-pane.tsx */\n- <span aria-hidden ...>\n+ <span aria-hidden=\"true\" ...>\n    <ChatTeardrop size={14} weight=\"fill\" />\n  </span>\n\n  /* ai-thread-pane.tsx */\n- <span className=\"flex size-7 ...\" style={...}>\n+ <span aria-hidden=\"true\" className=\"flex size-7 ...\" style={...}>\n    <Sparkle ... />\n  </span>\n```\n\nThe HTML5 boolean-attribute form (`aria-hidden` without `=\"true\"`) is technically equivalent, but linters + future maintainers read intention more clearly when the value is explicit. The AI-thread pane icon was missing the attr entirely; added for parity.\n\nBoth header text labels (\"Thread · N replies\" / \"Ask Helios\") provide the accessible name; the icons are purely decorative.\n\n**Verification:** chat 107/107 tests pass; touched files typecheck clean.\n\n**Sources:** audit:thread-huddle Gaps #5 + #7 (workflow `wf_14d2b01a-8de`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.660Z","updatedAt":"2026-06-05T01:29:11.660Z"},{"id":"07fa1a66-2b90-42c8-98fd-97427702e9cf","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-67-modal-focus-rings","type":"changed","scope":"chat","summary":"Round 67 — focus rings on 3 chat-specific modal controls (NewPoll remove-option button, LinkWorkPicker search row, ExtractTasks priority select). Keyboard users can finally see what they're about to fire.","body":"Surfaced by modal audit gaps #5, #9, #11 (workflow `wf_9ef41eb9-bc5`).\n\nThree chat-specific modal controls had `outline-none` or no focus-visible affordance:\n\n1. **NewPollModal — remove-option button** (×): no focus ring meant keyboard users navigating through options couldn't see which option's remove button they were about to fire. Real \"deleted the wrong option\" risk.\n2. **LinkWorkPicker — search input**: bare `bg-transparent` input with `outline-none`; the row IS the field visually, so the ring needs to live on the row.\n3. **ExtractTasksModal — priority chip select**: `bg-transparent` + `outline-none` → near-invisible focus state on the priority pill.\n\n### Fixes (chat-scoped, not the shared Button primitive)\n\n**Poll remove-option:**\n\n```diff\n- className=\"flex size-6 ... rounded transition-colors hover:bg-[var(--bg-hover)]\"\n+ className=\"flex size-6 ... rounded transition-[background-color,transform,box-shadow] duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] hover:bg-[var(--bg-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-[var(--color-module-chat)] focus-visible:ring-offset-[var(--bg-default)] active:scale-[0.92]\"\n```\n\nDonut ring (chat-accent 2 px + bg-default 1 px offset) — same vocab as round 37's hover-action toolbar rings + tactile press at 0.92.\n\n**LinkWorkPicker search row** (focus-within on the wrapper, not the input — the row IS the field):\n\n```diff\n  <div\n+   className=\"... focus-within:shadow-[inset_0_-2px_0_0_var(--color-module-chat)]\"\n    className=\"flex items-center gap-2 border-b px-4 py-2\"\n    style={{ borderColor: 'var(--border-subtle)' }}\n  >\n    <MagnifyingGlass ... />\n    <input className=\"... outline-none ...\" />\n  </div>\n```\n\n`focus-within` triggers when any descendant has focus → paints a 2 px chat-accent underline along the bottom of the search row. Reads as \"this is the focused field\" without breaking the bare-input visual.\n\n**ExtractTasks priority select:**\n\n```diff\n  className=\"... bg-transparent ... outline-none\"\n+ className=\"... bg-transparent ... outline-none transition-[background-color,box-shadow] duration-150 hover:bg-[var(--bg-hover)] focus-visible:bg-[var(--bg-hover)] focus-visible:shadow-[0_0_0_2px_color-mix(in_oklch,var(--color-module-chat)_55%,transparent)]\"\n```\n\nHover + focus-visible both add a bg tint, plus focus-visible adds a 2 px chat-accent outer shadow ring.\n\n### Why NOT the shared `<Button>` primitive\n\nThe audit also flagged `dialogs.tsx` Cancel/Submit buttons (which use the shared `<Button>` from `@helios/ui`). The Button primitive does NOT have focus-visible styling. Adding it WOULD affect every button surface across the app (CRM dialogs, HRM forms, settings tabs, marketing CTAs, etc.) — that's a cross-cutting design-system change that should land in its own focused round with primitive-level testing, not folded into chat polish.\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** modal audit gaps #5, #9, #11 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.935Z","updatedAt":"2026-06-05T01:29:11.935Z"},{"id":"a7e0f586-344b-4abd-8dca-9c0418bcb826","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-81-typing-grammar-i18n","type":"changed","scope":"chat","summary":"Round 81 — typing-indicator grammar moves from English-fragment concatenation to full-sentence i18n templates per count tier. Translators can now render correct gender / number / verb-position for non-English languages.","body":"Surfaced by presence audit gap #6 (workflow `wf_9ef41eb9-bc5`).\n\nThe previous `formatLabel` concatenated string fragments — `chat.typing.and`, `chat.typing.is_typing`, `chat.typing.are_typing` — to build the typing indicator text. The approach assumed English's subject-verb-object word order and never-changing verb form. Broke for:\n\n- **German** — verb at clause end (\"Alice und Bob tippen gerade\")\n- **Japanese** — particles + verb-final (\"Aliceと Bobが入力しています\")\n- **Arabic** — RTL + gender agreement on the verb form\n- **French** — gender agreement (\"Alice est en train d'écrire\" vs \"Alice et Bob sont en train d'écrire\")\n\n### Fix — full-sentence templates per count tier\n\n```ts\nif (names.length === 1) {\n  return tt('chat.typing.one_typing', '{name} is typing', { name: names[0] ?? '' });\n}\nif (names.length === 2) {\n  return tt('chat.typing.two_typing', '{a} and {b} are typing', {\n    a: names[0] ?? '', b: names[1] ?? '',\n  });\n}\nif (names.length <= 4) {\n  return tt('chat.typing.few_typing', '{list}, and {last} are typing', {\n    list: names.slice(0, -1).join(', '),\n    last: names.at(-1) ?? '',\n  });\n}\nreturn tt('chat.typing.many_typing', '{count} people are typing', { count: names.length });\n```\n\nEach tier is a COMPLETE sentence with named slots. Translators can re-order, conjugate, and gender-agree freely:\n\n- French 2-tier: `{a} et {b} sont en train d'écrire`\n- German 3-4: `{list} und {last} tippen gerade`\n- Japanese 1: `{name}さんが入力しています`\n\n### What we removed\n\nThe legacy fragment keys (`chat.typing.is_typing`, `chat.typing.are_typing`, `chat.typing.and`) are NO LONGER REFERENCED by `formatLabel`. They stay in i18n catalogs as no-op until the next translation review can prune them — removing fragment keys mid-flight risks breaking any other consumer that depends on them.\n\n### Type-safety note\n\n`formatLabel` previously typed its `tt` param as `(k, fallback) => string` — no third-arg support. Adding the values arg required matching the `TranslationValues` shape exactly (`Record<string, string | number | bigint | Date | null | undefined>`) so TS's function-parameter contravariance accepts the host `useTranslation()` return type. Introduced a local `TtVars` alias for readability.\n\nNew i18n keys: `chat.typing.one_typing`, `chat.typing.two_typing`, `chat.typing.few_typing`, `chat.typing.many_typing`.\n\n**Verification:** chat 107/107 tests pass; typing-indicator typecheck clean.\n\n**Sources:** presence audit gap #6 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.343Z","updatedAt":"2026-06-05T01:29:12.343Z"},{"id":"60bd714b-ff00-481a-9af9-59bb3830e178","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-84-saved-pulse-type-tokens","type":"changed","scope":"chat","summary":"Round 84 — three leftover hardcoded `10.5px` font literals in `saved-popover` + `pulse-popover` migrate to `--text-chat-caption`. Continuation of round 53's drift cleanup; the sidebar pulse + saved family now scales coherently on ultrawide.","body":"Continuation of round 53 (chrome-literal → fluid-token drift cleanup). Three literals were missed in that sweep:\n\n1. **`saved-popover.tsx:118`** — count badge in the popover header (`{items.length}` chip):\n   ```diff\n   - fontSize: '10.5px',\n   + fontSize: 'var(--text-chat-caption, 10.5px)',\n   ```\n\n2. **`pulse-popover.tsx:272`** — mention-count badge in the popover header (`{totalMentions}` chip):\n   ```diff\n   - fontSize: '10.5px',\n   + fontSize: 'var(--text-chat-caption, 10.5px)',\n   ```\n\n3. **`pulse-popover.tsx:561`** — per-row \"jump to channel\" hover chip text:\n   ```diff\n   - className=\"... text-[10.5px] opacity-0 transition-opacity ...\"\n   - style={{ color: 'var(--fg-muted)' }}\n   + className=\"... opacity-0 transition-opacity ...\"\n   + style={{\n   +   fontSize: 'var(--text-chat-caption, 10.5px)',\n   +   color: 'var(--fg-muted)',\n   + }}\n   ```\n\nAll three are count chips / micro-labels in the sidebar's Saved + Pulse popovers — visible on every popover open, so the fluid-scale upgrade compounds for users on wide displays who triage messages from these surfaces frequently.\n\nNet visual delta on a 1440 px laptop: imperceptible. On a 32\" ultrawide: the chips grow alongside the rest of the chat scale (~10.5 → ~11.5 px) instead of staying pinned to laptop-resolution.\n\n**Verification:** chat 107/107 tests pass; saved-popover + pulse-popover typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.490Z","updatedAt":"2026-06-05T01:29:12.490Z"},{"id":"5613b670-2139-492b-b42e-8fbdebc4685a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-pay-at-signature","type":"added","scope":"clients","summary":"After accepting a quote, the client sees a \"Pay now\" button on it the moment its invoice is raised.","body":"Tightened the sign → pay loop in the client portal (ONB-2). On the portal\nQuotations page, an accepted quote that has had its invoice raised now shows a\n\"Pay now\" button inline — the client signs the quote and pays for it in the same\nplace, without hunting for the invoice. The quotations action surfaces the linked,\nstill-payable invoice (matched via the invoice's source quotation), and the button\nreuses the existing client-pay checkout. Auto-raising the invoice at signature\ntime (one-click sign-and-pay) is a follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.764Z","updatedAt":"2026-06-05T01:29:12.764Z"},{"id":"e39069e8-c436-496f-b3ba-6db1085e29dd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"clients-renewals-datatable","type":"changed","scope":"clients","summary":"Renewals page now uses the unified DataTable — sortable columns, search, selection, and remembered view prefs.","body":"The Renewals table (Clients › Renewals) moved from a hand-rolled HTML table to the\nshared, polished DataTable. Same data + actions (Quote / Renew / Churn, bulk\n\"Mark churned\", the horizon chips), now with sortable columns, a search box,\nunified selection + bulk bar, the refined hover/selected styling, and remembered\ndensity / column / page-size preferences (`tableId`). First of a wider pass\nstandardizing the app's tables on the one DataTable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.813Z","updatedAt":"2026-06-05T01:29:12.813Z"},{"id":"9b5a0cb8-8ffb-460e-904d-9dd09729543d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-event-company","type":"changed","scope":"crm","summary":"The deal stage-changed event now carries the deal's company so downstream automation can link to the client.","body":"The `crm.deal.stage_changed` domain event now includes the deal's `companyId`\n(nullable). It's denormalised onto the event so subscribers — notably the projects\ndeal-won handler that spawns a delivery project — can link to the client without a\ncross-module lookup. Purely additive; existing subscribers ignore the new field.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.078Z","updatedAt":"2026-06-05T01:29:13.078Z"},{"id":"80486038-a8b1-4eca-a12b-48907c920c12","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-codes-datatable","type":"changed","scope":"payroll","summary":"Payroll codes (earnings, deductions, taxes) now use the unified DataTable — search, sortable columns, remembered prefs.","body":"All three Payroll › Codes tabs (earning, deduction, and tax codes) moved from\nhand-rolled HTML tables to the shared DataTable. Same columns and the same\nEdit / Archive / Delete per-row actions (now in a consistent row menu) plus the\n\"+ New …\" button in the table toolbar, gaining a search box, sortable columns,\nthe polished hover styling, and remembered density / column / page-size prefs\n(`tableId`). The statutory-catalog installer card, the create/edit dialogs, and\nthe tax bracket editor are unchanged. Part of the app-wide table standardization\npass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.384Z","updatedAt":"2026-06-05T01:29:14.384Z"},{"id":"d8a51352-3d97-49f9-926d-4a050e6a3fa4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-61-typing-vs-huddle-badge","type":"changed","scope":"chat","summary":"Round 61 — avatar status badges for `typing` and `in_huddle` no longer render identically. `in_huddle` gets an inset white ring + outer halo (active-call presence); `typing` switches to the same `helios-pulse-dot` rhythm as the typing-indicator dots.","body":"Surfaced by presence audit gap #3 (workflow `wf_9ef41eb9-bc5`).\n\n`packages/ui/src/primitives/avatar.tsx` rendered both `typing` and `in_huddle` as the exact same `module-chat solid dot + ui-breath` — visually indistinguishable. A user actively typing was indistinguishable from a user in a huddle in the same avatar stack.\n\n### After\n\n**`in_huddle`** — solid module-chat dot, `ui-breath` animation kept (slow 2 s scale 0.9→1.08 + opacity 0.5→1), plus:\n\n```diff\n  style={{\n    background: 'var(--color-module-chat)',\n+   boxShadow:\n+     '0 0 0 2px var(--bg-default),\n+      inset 0 0 0 1.5px color-mix(in oklch, white 70%, transparent),\n+      0 0 8px -1px color-mix(in oklch, var(--color-module-chat) 55%, transparent)',\n  }}\n```\n\n- `0 0 0 2px var(--bg-default)` — the existing crop-ring (no regression).\n- `inset 0 0 0 1.5px white-mix` — bright inner ring reads as \"active participant\".\n- `0 0 8px -1px module-chat 55%` — soft outer halo signals \"live signal.\"\n\nThe breath + halo + ring trio reads as \"this person is in the call\" — modeled on the visual language of an iOS active-call pulse.\n\n**`typing`** — drops `ui-breath` in favor of the global `helios-pulse-dot` keyframe (1.2 s ease-in-out, opacity 1→0.35):\n\n```diff\n- <span aria-hidden className={cn(baseClass, 'ui-breath')}\n-   style={{ background: 'var(--color-module-chat)' }}\n- />\n+ <span aria-hidden className={baseClass}\n+   style={{\n+     background: 'var(--color-module-chat)',\n+     animation: 'helios-pulse-dot 1.2s ease-in-out infinite',\n+   }}\n+ />\n```\n\nThe 1.2 s opacity-pulse is the EXACT same rhythm the typing-indicator dots (`TypingDots` in `typing-indicator.tsx`) use — when a user is typing, their avatar status dot pulses in sync with the typing dots over the channel. The motion language unifies across surfaces.\n\nReduced-motion users are already covered by round 44's `[style*=\"helios-pulse-dot\"] { animation: none !important }` for the typing variant, and round 35's `.ui-breath { animation: none !important }` for the in-huddle ring + halo. Both states collapse to static dots that remain visually distinct (in_huddle ring vs typing bare).\n\n**Verification:** chat 107/107 tests pass; avatar.tsx typecheck clean.\n\n**Sources:** presence audit gap #3 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.680Z","updatedAt":"2026-06-05T01:29:11.680Z"},{"id":"c7a27b21-48bb-4ce9-ad3f-f493a7116a47","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-78-ai-thread-followup-chips","type":"added","scope":"chat","summary":"Round 78 — AI thread pane shows 3 suggested-follow-up chips below the latest AI message (Summarize / Next steps / Refine). Mirrors the Claude / ChatGPT / Perplexity \"what to ask next\" pattern. Click dispatches `helios:chat:ai-prefill` for composer pickup.","body":"Surfaced by AI-thread research Pattern 8 (workflow `wf_9ef41eb9-bc5`).\n\nEvery modern AI chat surface (Claude.ai, ChatGPT, Perplexity, Vercel v0) renders 3 suggested-follow-up chips below the latest model response. Standardised UX: outlined chips with the brand accent, click pre-fills the composer rather than auto-sending so the user can edit.\n\n### What lands\n\nA new `<AiFollowupChips>` component renders below the LAST AI message in the conversation:\n\n```tsx\n{m.authorType === 'ai' && m.id === messages.at(-1)?.id && <AiFollowupChips />}\n```\n\nThree static, work-OS-appropriate suggestions for v1:\n\n```ts\nconst suggestions = [\n  tt('chat.ai_thread.followup.summarize', 'Summarize the key points'),\n  tt('chat.ai_thread.followup.next_steps', 'What should I do next?'),\n  tt('chat.ai_thread.followup.refine', 'Can you make it more concise?'),\n];\n```\n\nClick → dispatches `helios:chat:ai-prefill` window event with the suggested text. Same event channel as the round-72 \"Try again\" button — the composer can listen once + handle both. (Listener still pending in `composer-tiptap.tsx`; will land in a follow-up round.)\n\n### Visual\n\n- **AI-tinted outlined chips** — 8% AI-500 background + 30% AI-500 border + AI-600 text. Same family as round 73's jump pill and round 72's chip-row.\n- **Sparkle leading icon** at 10 px fill — marks each as an AI suggestion.\n- **`hover:-translate-y-px hover:shadow`** — subtle lift to invite the press. `active:scale-[0.97]` for touch feedback. Matches round 59's vocab.\n- **`focus-visible:ring-2`** in AI-500 — keyboard journey reads as AI surface.\n- **`role=\"group\"` + `aria-label=\"Suggested follow-ups\"`** — announces the cluster as one named region.\n- `ml-10` lines up past the avatar gutter (matches round-72 chip row positioning).\n\n### Why static + universal for v1\n\nPer-response suggestions need server-side prompt generation + a metadata slot on `chat_messages` — both bigger lifts. Static universal prompts give 80% of the UX value at 5% of the implementation cost. The three picks (summarize / next steps / refine) are the most-tapped categories per the research lens (Perplexity / Claude data referenced).\n\n### What we did NOT do (deferred)\n\n- **Per-response suggestions from AI metadata** — needs server-side suggestion generation in the chat AI pipeline, plus a metadata field on `chat_messages`. Migration-gated.\n- **Composer event listener** for `helios:chat:ai-prefill` — registered handlers in composer-tiptap.tsx. A small follow-up; both round 72 and 78 dispatch the event, neither receives it yet.\n- **Hide on draft-active** — should disappear when the composer has content. Needs cross-component state. Follow-up.\n\nNew i18n keys: `chat.ai_thread.followup_group`, `chat.ai_thread.followup.summarize`, `chat.ai_thread.followup.next_steps`, `chat.ai_thread.followup.refine`.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.\n\n**Sources:** AI-thread research Pattern 8 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.226Z","updatedAt":"2026-06-05T01:29:12.226Z"},{"id":"9ca37b94-52cd-4c4c-9d2a-6988159def89","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-90-ai-regenerate-real-prompt","type":"changed","scope":"chat","summary":"Round 90 — AI \"Try again\" chip now prefills the composer with the user's ACTUAL previous prompt, not a generic \"Try that again…\" preface. The chip is a real retry now, not a stub.","body":"Surfaced as a follow-up to rounds 72 + 82.\n\nRound 72 added a Try-again chip below every AI message that dispatched `helios:chat:ai-prefill` with a hardcoded preface text. Round 82 closed the loop by wiring the composer to listen. The UX got the user to a focused composer, but the prefill was a generic \"Try that again with a different angle:\" — leaving the user to retype their actual question.\n\n### Fix\n\nIn the message-map loop, walk backwards from each AI message to find the immediately-preceding non-AI message (the user prompt that produced this reply). Pass that text down to `<AiMessageActions>`:\n\n```ts\nconst previousUserPrompt =\n  m.authorType === 'ai'\n    ? (() => {\n        for (let j = i - 1; j >= 0; j -= 1) {\n          const candidate = messages[j];\n          if (candidate && candidate.authorType !== 'ai') {\n            return candidate.bodyPlain ?? '';\n          }\n        }\n        return '';\n      })()\n    : '';\n```\n\nThe Try-again chip's click handler uses the prompt when available, falls back to the round-72 generic preface for thread-roots (an AI message with no prior user message — happens only if the AI was the very first entry, which is rare but defensive):\n\n```ts\nconst text =\n  previousUserPrompt.trim().length > 0\n    ? previousUserPrompt\n    : tt('chat.ai_thread.regenerate_preface', 'Try that again with a different angle:');\nwindow.dispatchEvent(new CustomEvent('helios:chat:ai-prefill', { detail: { text } }));\n```\n\n### What this changes in the user's flow\n\n**Before:** User asks \"How many CRM contacts are in 'qualified'?\" → AI replies. User clicks Try again → composer prefills \"Try that again with a different angle: \" → user has to type their question AGAIN.\n\n**After:** Same scenario → composer prefills with \"How many CRM contacts are in 'qualified'?\" → user can immediately edit (add \"...by region\" or \"...in Q3\" or whatever) and hit Send. Per-message regenerate via the round-82 prefill event, with no server changes.\n\n### Why not a true server-side regenerate\n\nThe audit's full ask was a `chat.ai.regenerate(messageId)` action that:\n1. Finds the message\n2. Finds its triggering prompt\n3. Re-runs the AI with the same prompt\n4. Replaces or appends after the original AI message\n\nThat's a real server feature — needs the action, conversation-history pruning logic, and message-replacement semantics. The UI-only path here delivers 90% of the value (user re-runs with a known prompt + edits in flight) without the server work.\n\n### What we did NOT change\n\n- The fallback preface key (`chat.ai_thread.regenerate_preface`) stays since the thread-root fallback path still uses it.\n- Round 78's follow-up chips still use static prompts — they're suggestions for NEW directions, distinct from regenerate.\n\n**Verification:** chat 107/107 tests pass; ai-thread-pane typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.607Z","updatedAt":"2026-06-05T01:29:12.607Z"},{"id":"81e9f2d7-0272-4ab8-ac13-19f145c0d61a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-contacts","type":"added","scope":"crm","summary":"Deals can now have multiple contacts, each with a buying role and an optional primary flag.","body":"A deal is no longer limited to a single contact. The new deal↔contact link\n(`crm.deal.contact.link` / `unlink` / `set_primary` / `list`) lets you attach\nseveral people to a deal, each tagged with their buying role — decision maker,\nchampion, influencer, economic buyer, technical buyer, evaluator, blocker, or\nother.\n\nOne contact can be marked the deal's **primary** (kept in sync with the deal's\nexisting contact field, so the board and lists still show the right person);\nre-linking updates a role without disturbing the primary, and removing the\nprimary clears it on the deal. Linking is org-scoped and validated on both the\ndeal and the contact.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.037Z","updatedAt":"2026-06-05T01:29:13.037Z"},{"id":"fd066f4a-f748-4f53-9609-8c3417f0093a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-polished-dropdowns","type":"changed","scope":"crm","summary":"CRM deal + pipeline dropdowns are now searchable pickers with colour-coded stage swatches.","body":"The deal and pipeline dropdowns moved from plain native selects to the polished\n`Picker` component:\n\n- The \"Add deal\" stage selector and the per-card \"move stage\" control now show a\n  colour swatch for each stage and are searchable; picking a stage still\n  pre-fills the win-probability from that stage.\n- The pipeline manager's pipeline selector, the Open / Won / Lost type choosers\n  (now colour-coded), and the \"move deals to\" fallback selectors are all the\n  same searchable picker, so deleting a stage or pipeline and reassigning its\n  deals is quicker to navigate.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.126Z","updatedAt":"2026-06-05T01:29:13.126Z"},{"id":"b2261085-0b84-4645-b76e-c1c5531d49a4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-duplicate-charge-guard","type":"fixed","scope":"payments","summary":"Retrying a payment for the same invoice no longer charges the customer twice.","body":"Creating a checkout session for an invoice (or any concrete payment source) now\nguards against a duplicate charge. Before minting a new provider intent it:\n\n1. reconciles any in-flight payment for that source directly with the provider —\n   catching the case where money was already captured but a missed webhook left\n   the invoice showing unpaid (the exact state that tempts a retry);\n2. refuses if a charge for that source has already succeeded; and\n3. reuses an existing open checkout session whose amount and currency still match\n   instead of creating a second one.\n\nPartial / installment payments are unaffected: a changed remaining balance no\nlonger matches an open session, so a legitimate second payment still gets its\nown checkout.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.508Z","updatedAt":"2026-06-05T01:29:13.508Z"},{"id":"a95eca6f-a653-4b12-99d6-4137bb148195","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-invoices-quotations-pagination","type":"fixed","scope":"clients","summary":"Client-portal invoice and quote lists are now paginated — clients with long histories no longer silently lose rows.","body":"The client-portal **Invoices** and **Quotations** lists fetched a hard-capped\n200 rows with no way to see beyond that — a client with a longer billing\nhistory silently lost the rest. Both now use keyset cursor pagination (newest\nfirst) with a **Load more** button, matching the operator-side invoice list.\nDraft invoices/quotes are excluded in the query (not post-filtered), so each\npage is a full page of customer-visible rows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.567Z","updatedAt":"2026-06-05T01:29:14.567Z"},{"id":"167855a9-e82f-429b-893d-c3317a865ad5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"recruitment-reports-table-primitive","type":"changed","scope":"recruitment","summary":"Recruitment reports' source-effectiveness table restyled to the shared table look for consistency.","body":"The \"Source effectiveness\" table on Recruitment › Reports now renders through the\nshared `Table` primitive (consistent header, row dividers, and hover with the\nrest of the app) instead of a one-off hand-styled table. It's a small aggregate\nsummary, so it stays a plain table — not a full DataTable. Same data, no\nbehavior change. Part of the table-consistency pass (Tier 2).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.070Z","updatedAt":"2026-06-05T01:29:15.070Z"},{"id":"88defc52-322c-48ba-9254-736a16e72df0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-68-huddle-aria-pressed","type":"fixed","scope":"chat","summary":"Round 68 — huddle toolbar buttons now expose `aria-pressed` for both `active` and `highlight` visual states. Was only firing for screen-share; the mic, camera, and other toggles announced no pressed state to AT users.","body":"Surfaced by huddle audit gap #3 (workflow `wf_9ef41eb9-bc5`).\n\n`ToolbarBtn` in `huddle-stage.tsx` had `aria-pressed={highlight}` — but the prop usage was inconsistent across call sites:\n\n- **Screen-share** passed both `active` AND `highlight` → got `aria-pressed`.\n- **Mic** passed only `active={micEnabled}` + `danger={!micEnabled}` → `aria-pressed` was undefined.\n- **Camera / Reactions / Record** — same as mic.\n\nSo screen-reader users heard the button's `aria-label` flip (\"Mute\" / \"Unmute\" / \"Stop sharing screen\") but never the pressed state. With `aria-pressed`, AT can also announce \"toggle button, pressed\" / \"toggle button, not pressed\" — concise + state-clear.\n\n### Fix\n\n```diff\n- aria-pressed={highlight}\n+ aria-pressed={Boolean(highlight ?? active)}\n```\n\nWhichever of the two visual props is truthy maps to `aria-pressed`. Screen-share still works (`highlight` truthy when enabled). Mic now flips between pressed/not-pressed as `active` flips. Reactions, raise-hand, captions, etc. all inherit the semantic without per-call-site changes.\n\n`Boolean(... ?? ...)` instead of `?? false` to be explicit that we want a real boolean (not `undefined` which collapses to \"mixed\" in some ATs).\n\n**Verification:** chat 107/107 tests pass.\n\n**Sources:** huddle audit gap #3 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:11.943Z","updatedAt":"2026-06-05T01:29:11.943Z"},{"id":"6eab09a6-2715-400a-a66f-8f6b3d02e5e9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-74-modal-scroll-lock","type":"fixed","scope":"chat","summary":"Round 74 — `<Modal>` primitive now locks body scroll while mounted, with stack-safe ref counting so multiple modals open in sequence don't fight over the body's `overflow` style.","body":"Surfaced by modal audit gap #2 (workflow `wf_9ef41eb9-bc5`).\n\nThe legacy `<Modal>` in `apps/web/src/components/primitives.tsx` (used by chat NewPollModal, ForwardMessageDialog, MessageDetailModal, ExtractTasksModal, and many surfaces app-wide) had no body-scroll lock while open. Mobile users who panned past the modal's own scroll area ended up scrolling the page underneath — iOS Safari rubber-banded over the backdrop; the dismiss IME on the modal's inputs could be hidden by the page scroll.\n\n### Fix — stack-safe ref counting\n\n```ts\nuseEffect(() => {\n  if (typeof document === 'undefined') return;\n  const body = document.body;\n  const prevOverflow = body.style.overflow;\n  const prevCount = Number(body.dataset.heliosModalLockCount ?? '0');\n  body.dataset.heliosModalLockCount = String(prevCount + 1);\n  if (prevCount === 0) {\n    body.style.overflow = 'hidden';\n  }\n  return () => {\n    const nextCount = Number(body.dataset.heliosModalLockCount ?? '1') - 1;\n    if (nextCount <= 0) {\n      delete body.dataset.heliosModalLockCount;\n      body.style.overflow = prevOverflow;\n    } else {\n      body.dataset.heliosModalLockCount = String(nextCount);\n    }\n  };\n}, []);\n```\n\n### Why the ref-count\n\nThe naive `body.style.overflow = 'hidden'` + restore on unmount FAILS when two modals are open in sequence:\n\n1. Modal A mounts → `body.overflow = 'hidden'` (saved prevOverflow = `''`)\n2. Modal B mounts → reads `body.overflow = 'hidden'` (saved prevOverflow = `'hidden'`)\n3. Modal A unmounts → restores `''`\n4. Modal B is still open but body now scrolls\n\nThe ref count on `document.body.dataset.heliosModalLockCount` increments on mount, decrements on unmount, and only restores the saved overflow when the count hits zero. Safe for any depth of modal stacking.\n\n### Why not `inert` on the rest of the DOM\n\nThe audit also suggested `inert` on the app root. That requires Radix-portal awareness — if the modal IS a portal child of the app root, inerting the root inerts the modal too. Needs a `body > * (except portal-host)` ref scheme. Heavier; separate round.\n\n### Scope\n\nThe legacy `<Modal>` is what every chat-module modal uses today. The Radix `<Modal>` from `@helios/ui` has scroll-lock built in via Radix Dialog. So this fix lifts the legacy primitive to parity.\n\n**Verification:** chat 107/107 tests pass; primitives.tsx typecheck clean.\n\n**Sources:** modal audit gap #2 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.210Z","updatedAt":"2026-06-05T01:29:12.210Z"},{"id":"272c4925-46b6-4dbc-80d0-22e4e94c4524","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-round-88-image-annotator-inert","type":"fixed","scope":"chat","summary":"Round 88 — ImageAnnotator finally has a real focus trap. While open, every body-level sibling outside the modal is marked `inert` so Tab can't escape to background controls. Restored on unmount via ref-tracked element list.","body":"Surfaced by modal audit gap #8 (workflow `wf_9ef41eb9-bc5`).\n\nThe ImageAnnotator had `role=\"dialog\"` + `aria-modal=\"true\"` (both shipped earlier) but neither attribute actually prevents Tab from leaving the modal. Modern browsers honour `inert` (Baseline 2023; Chrome 102, Firefox 112, Safari 15.5+) — that's the spec-blessed way to trap focus without a focus-trap library.\n\n### Fix\n\n```ts\nuseEffect(() => {\n  if (typeof document === 'undefined') return;\n  const dialog = containerRef.current?.closest('[role=\"dialog\"]') ?? containerRef.current;\n  if (!dialog) return;\n  // Walk up to the body-level ancestor of the dialog.\n  let bodyAncestor: Element = dialog;\n  while (bodyAncestor.parentElement && bodyAncestor.parentElement !== document.body) {\n    bodyAncestor = bodyAncestor.parentElement;\n  }\n  const inerted: HTMLElement[] = [];\n  for (const child of Array.from(document.body.children)) {\n    if (child === bodyAncestor) continue;\n    if (!(child instanceof HTMLElement)) continue;\n    if (child.hasAttribute('inert')) continue;\n    child.setAttribute('inert', '');\n    inerted.push(child);\n  }\n  return () => {\n    for (const el of inerted) el.removeAttribute('inert');\n  };\n}, []);\n```\n\n### Behavior\n\n- **Walk up to body-level ancestor.** ImageAnnotator doesn't use `createPortal` — it renders inline where the composer mounted it. The dialog's body-level ancestor is the route container (or whatever React tree root contains the composer). Everything else at body level (header, nav, sidebar, toolbar) gets `inert`.\n- **Skip already-inert elements.** If another modal opened before us already inerted the same elements, the `hasAttribute('inert') continue` guard prevents double-marking. On cleanup, we only remove what WE added (`inerted` array tracks our additions).\n- **Per-mount lifecycle.** Empty dep array = run once on mount, cleanup once on unmount. No re-runs.\n\n### What `inert` gives you (the spec)\n\n- `tabindex` walks skip every descendant\n- `:focus-visible` won't paint\n- Click + pointer events are absorbed at the inert root (don't bubble up)\n- Screen readers skip inert subtrees entirely\n- `document.activeElement` can't land inside inert\n\n### What this does NOT do\n\n- **Apply inert WHILE the user is editing.** It applies on mount + clears on unmount. If the user opens a nested popover from inside the ImageAnnotator (the existing emoji picker is portal-rendered to body level — would be re-inerted), this could be an issue. Audit shows no nested modals in the ImageAnnotator path today.\n- **Save/restore previously-inert elements.** We only restore what THIS modal added. If something else was inert beforehand, we leave it alone (correct behaviour).\n- **Polyfill for older browsers.** `inert` is Baseline 2023; older browsers ignore the attribute and Tab can still escape. The remaining `role=\"dialog\"` + `aria-modal=\"true\"` still signal to assistive tech that the modal is the active layer.\n\n### Pairing with round 74\n\nRound 74 added body-scroll lock to the legacy `<Modal>` primitive. ImageAnnotator is NOT the legacy Modal — it's its own component with a full-screen overlay. The inert + the existing dialog ARIA together give it the focus-trap + scroll-lock equivalent without retrofitting the shared primitive.\n\n**Verification:** chat 107/107 tests pass; image-annotator typecheck clean.\n\n**Sources:** modal audit gap #8 (`wf_9ef41eb9-bc5`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.567Z","updatedAt":"2026-06-05T01:29:12.567Z"},{"id":"3f39e401-622a-47aa-8a05-03c227672dd1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-custom-fields","type":"added","scope":"crm","summary":"Admins can now define custom fields for CRM contacts, companies, deals, and leads.","body":"CRM now supports custom fields. Users with the new `crm:custom_field:manage`\npermission can define fields per entity type (contact / company / deal / lead)\nvia `crm.custom_field.create / update / archive / list` — each with a stable\nkey, a label, a type (text, number, date, boolean, select, or URL), options for\nselect fields, a required flag, and a display order. Keys are unique per entity,\nthe key is immutable once set (so stored values never orphan), and archiving a\nfield hides it without losing data.\n\nThis is the definitions foundation; field values on records and the field-builder\nUI follow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.017Z","updatedAt":"2026-06-05T01:29:13.017Z"},{"id":"bc65e16b-ebb3-4d85-b1b9-607fd1e2f450","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-create-stage-aware","type":"changed","scope":"crm","summary":"The \"Add deal\" form now offers your pipeline's actual stages, and deals can be created directly on any stage.","body":"Creating a deal is now pipeline-aware:\n\n- The \"Add deal\" form lists your configured pipeline's open stages (renamed or\n  custom stages included) instead of the five fixed defaults, and starts the\n  win-probability at the chosen stage's value.\n- `crm.deal.create` accepts a `stageId` to place a deal directly on a specific\n  stage (org-scoped); without one it still lands on the default pipeline's stage\n  matching the legacy stage name.\n- Deal stage labels are no longer limited to the five seed names, so a deal that\n  sits on a renamed/custom stage reads back correctly across the app.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.066Z","updatedAt":"2026-06-05T01:29:13.066Z"},{"id":"16165e07-04b9-4e67-8e43-7d8480c57468","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"email-attachment-claim-honest","type":"fixed","scope":"sales","summary":"Receipt / credit-note / statement emails only say \"attached as a PDF\" when the PDF was actually attached.","body":"The payment-receipt, credit-note, and statement-of-account emails printed \"… is attached as a PDF\" unconditionally, but the PDF render is best-effort (wrapped in a try/catch that falls back to no attachment). So if the render ever failed, the email still claimed an attachment that wasn't there — the same mismatch that prompted the attachment-delivery fix. Each composer now renders the PDF first and only includes the \"attached as a PDF\" line (in both the text and HTML bodies) when the attachment was actually built. With attachment delivery now working, this is the belt-and-suspenders that keeps the wording honest in the rare render-failure case.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.338Z","updatedAt":"2026-06-05T01:29:13.338Z"},{"id":"781c96d0-7e4e-4eef-888f-8024599450c2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-reimbursements-datatable","type":"changed","scope":"payroll","summary":"Reimbursements table now uses the unified DataTable — sortable columns, search, and remembered view prefs.","body":"The Payroll › Reimbursements claims table moved from a hand-rolled HTML table to\nthe shared DataTable: same columns (employee, description, amount, status,\nactions) and the same per-row Approve / Reject flow for pending claims, now with\nsortable columns, a search box, polished styling, and remembered view prefs\n(`tableId`). Part of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.564Z","updatedAt":"2026-06-05T01:29:14.564Z"},{"id":"a8f517c3-3f3a-4464-b1da-42226bf026c3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-activity-type-icon-picker","type":"changed","scope":"crm","summary":"The activity timeline's \"log activity\" type selector is now an icon picker (note / call / meeting / task).","body":"The activity-type chooser in the CRM record timeline composer (on every deal,\ncontact, lead, and company page) moved from a plain select to the polished\npicker, showing each type's icon — matching the timeline rows' own iconography.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.826Z","updatedAt":"2026-06-05T01:29:12.826Z"},{"id":"6a476628-5f73-4d86-b32d-d75c79d12c4d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-overpayment-recorded-and-flagged","type":"fixed","scope":"sales","summary":"A duplicate payment capture is now recorded and flagged for review instead of being silently dropped.","body":"If a genuine double-capture ever lands on an invoice (two real provider charges\nbefore either records), the surplus payment is now **recorded** — the money\nreceived is never silently dropped — and the invoice raises a\n`sales.invoice.overpaid` signal for operator review (the operator refunds the\nsurplus manually; no money moves automatically). Previously a second payment on\na fully-paid invoice was rejected outright, leaving the captured funds\nunaccounted for.\n\nThe invoice still caps its status at paid, the paid-at timestamp is preserved,\nand `sales.invoice.paid` fires only on the first transition (revenue is\nrecognized once). Void and written-off invoices remain hard-blocked.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.574Z","updatedAt":"2026-06-05T01:29:13.574Z"},{"id":"0efe61b8-adb0-4cef-a216-75800ccb158b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-synthetic-charge-id-for-null","type":"fixed","scope":"payments","summary":"Payments from providers that omit a charge id are now deduplicated correctly.","body":"Some providers return a successful capture without a charge id. A null charge id\nescaped the deduplication index, so a re-delivered or re-polled success could\nrecord the same payment twice. The charge ledger now stores a stable synthesized\nreference derived from the payment intent when the provider gives no id, so every\nrecording path collapses to a single charge.\n\nSuch a payment can't be refunded through the provider API (there's no real charge\nreference), so the refund action now declines it with a clear message to refund\nin the provider dashboard instead of failing opaquely.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.646Z","updatedAt":"2026-06-05T01:29:13.646Z"},{"id":"bc5ffded-8bba-41c2-b0ed-1b1c601581a4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"projects-client-activity-notifications","type":"added","scope":"projects","summary":"Operators now get an in-app notification when a client posts a message, shares a file, or signs off a milestone.","body":"Closed the biggest gap from the portal audit: client activity on a project was\ninvisible to the team until someone happened to refresh. The client\ncollaboration actions now emit domain events\n(`projects.project.client_message_posted`, `projects.project.client_file_uploaded`,\n`projects.milestone.client_approval_changed`) — satisfying the module rule that\nevery write emits an event — and a new subscriber fans **client-initiated**\nactivity out as an in-app notification to every project member, linking\nstraight to the project's client tab.\n\nOperator replies and operator-shared files emit their event too (for the audit\ntrail and future webhooks) but don't notify the team about their own colleague's\naction. Email for these is deferred, consistent with the rest of the projects\nnotification surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.887Z","updatedAt":"2026-06-05T01:29:14.887Z"},{"id":"92b73cb6-7f83-4f1d-a6d9-dd3f43525198","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contact-row-opens-detail","type":"changed","scope":"crm","summary":"Clicking a contact in the CRM list now opens its detail page instead of the edit drawer.","body":"Clicking a row in the CRM contacts list now navigates to the contact's detail\npage (`/crm/contacts/$id`) — with its activity timeline, conversation, related\ncompany, and tasks — rather than popping the edit drawer. Editing is still one\nclick away from the row's \"⋯\" menu and from the detail page's inline fields.\nThis matches how deals already behave and makes the contact the hub it should be.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:12.989Z","updatedAt":"2026-06-05T01:29:12.989Z"},{"id":"9be1dcec-838c-47a6-8759-6a4655536e37","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-open-session-unique-index","type":"fixed","scope":"payments","summary":"A double-clicked \"Pay\" can no longer create two charges via a race.","body":"Hardened the duplicate-charge guard against a concurrent double-submit: a unique\nindex now permits at most one open checkout session per (source, amount,\ncurrency), so if two requests race past the in-app reuse check, only one session\nis created and both callers receive it — never two chargeable intents. The\nloser's provisional intent is retired so it's never reconciled. A one-time\npre-sweep collapses any pre-existing duplicate open sessions before the index is\nbuilt.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.566Z","updatedAt":"2026-06-05T01:29:13.566Z"},{"id":"3b41f347-1306-4166-b383-8eed4808b1ee","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-reconcile-intents-sweep","type":"fixed","scope":"payments","summary":"A background sweep now recovers payments captured when a webhook was never delivered.","body":"Added a reconciliation sweep that runs every couple of minutes and asks each\npayment provider directly whether a still-open payment has actually settled —\nthen records the charge (and marks the invoice paid / completes signup) if it\nhas. This is the safety net for the rare case where the provider captured the\nmoney but the webhook was never delivered AND the customer closed the return tab\nbefore it confirmed, which previously left the payment stranded.\n\nRecording stays fully idempotent: whichever path records first (webhook, return\npage, or this sweep) wins, and the others are silent no-ops.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.591Z","updatedAt":"2026-06-05T01:29:13.591Z"},{"id":"c6613c57-59e0-40f1-b2e7-b7007136392e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-webhook-retry-on-transient-failure","type":"fixed","scope":"payments","summary":"A transient error while processing a payment webhook now retries instead of dropping the payment.","body":"Previously, if processing a provider webhook hit a transient error (a database\nor event-bus hiccup mid-dispatch), the event was marked failed, the HTTP edge\nacknowledged it with 200, and the provider never retried — so a captured payment\ncould be silently lost and its invoice left unpaid. Redelivery was also a no-op\nbecause the deduplication short-circuited any event already on file.\n\nNow the edge replies 5xx on a transient failure so the provider retries within\nits backoff window, and the receiver re-dispatches any event that has not yet\nbeen fully processed. Recording stays idempotent, so a retry after success is a\nno-op. Permanent failures (malformed payloads, bad signatures) are still\nacknowledged so the provider stops re-sending.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.322Z","updatedAt":"2026-06-05T01:29:14.322Z"},{"id":"15e9e88f-1945-4262-be53-71fa33f4cc1b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-auto-invoice-on-quote-accept","type":"added","scope":"sales","summary":"New \"pay at signature\" setting auto-invoices a quotation the moment a client e-signs it.","body":"Added an opt-in **\"Pay at signature\"** workflow (ONB-2). When enabled in\nSales → Settings, a client accepting (e-signing) a quotation from their portal\nnow automatically converts it into an invoice and issues it, so they can pay\nright away — no operator round-trip. The invoice is linked back to the\nquotation and the client's portal \"Pay now\" button lights up immediately.\n\nThe conversion runs in a worker subscriber on `sales.quotation.accepted`, so it\ncovers both client-portal acceptance and operator-on-behalf acceptance. It is\ncareful by design:\n\n- **Off by default** — operators opt in per org.\n- **No double-invoicing** — if the quote already has an invoice (a manual\n  convert, or a duplicate event delivery), it does nothing.\n- **Respects approval** — if the accepted quote's total is over your invoice\n  approval threshold, the invoice is created as a draft and held for an\n  approver instead of auto-issuing.\n- The auto-invoice is attributed to the quotation's owner.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.094Z","updatedAt":"2026-06-05T01:29:15.094Z"},{"id":"3b06d28e-949b-48e4-9b5f-e52d1ccf2d75","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-detail-table-polish","type":"changed","scope":"sales","summary":"The invoice detail line-items table now matches the polished PDF — zebra striping, and the tax column/row is hidden when there's no tax.","body":"Brought the on-screen invoice detail in line with the polished invoice PDF:\n\n- **Zebra striping** on the line-items rows, so dense invoices are easier to scan (matching the PDF).\n- **Tax column hidden** when no line carries tax — a tax-free invoice no longer shows a column of \"0.00\".\n- **Totals tidy-up** — the Tax row only appears when there's tax, the Paid row only when a payment has been recorded (and is now shown as a `−` deduction), matching the PDF's conditional totals block.\n\nPresentational only; no figures changed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.819Z","updatedAt":"2026-06-05T01:29:15.819Z"},{"id":"ede5f9b4-1474-493d-a5ee-06e2ee9c8b58","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-pipeline-manager-ui","type":"added","scope":"crm","summary":"Operators can now create, rename, reorder, and delete sales pipelines and their stages from the deals board.","body":"The configurable-pipeline actions now have an operator UI. A \"Manage\npipelines\" slide-over on the deals board (visible to users with pipeline-manage\npermission) lets you:\n\n- Create a pipeline (seeded with the standard stages) and set the org default.\n- Rename a pipeline, switch the default, or delete a non-default pipeline —\n  moving any deals on it to a fallback pipeline.\n- Add, rename, reorder, and remove stages; set each stage's win probability,\n  mark it Open / Won / Lost, and give it a per-stage \"stalled deal\" threshold.\n- Delete a stage by reassigning its deals to another open stage in the pipeline.\n\nEvery change goes through the shared action layer and reflects on the board\nimmediately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.113Z","updatedAt":"2026-06-05T01:29:13.113Z"},{"id":"b69aec90-0756-4cb1-ad01-7d0d894704da","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-charge-resync-remediation","type":"added","scope":"payments","summary":"Admins can re-sync a captured payment to heal an invoice that didn't get marked paid.","body":"Added `payments.charge.resync` — an admin remediation that re-emits the\nsuccess events for a source's already-captured charges. Use it to heal a\ndownstream consumer that missed the original event (an invoice left unpaid, a\nsubscription not activated) without touching the provider. It's idempotent: the\nsales and subscription handlers deduplicate on the provider charge id, so\nre-syncing an already-applied charge is a no-op.\n\nShips with an integration test that also locks in the underlying fix — proving\nthe success events are durably written to the event outbox (the bug was that\nthey were being silently dropped).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.368Z","updatedAt":"2026-06-05T01:29:13.368Z"},{"id":"e0c2aa87-83b5-4144-a58a-f31f9228967a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-provider-intentretrieve-parity","type":"added","scope":"payments","summary":"Seven more payment providers can now self-verify a payment when a webhook is missed.","body":"Razorpay, Paddle, Paystack, Xendit, dLocal, Airwallex, and Midtrans now support\ndirect status verification, so the reconciliation sweep and the payment-return\npage can confirm a captured payment without waiting for a webhook (previously\nonly Stripe could). Each adapter reuses its existing authenticated API and treats\nonly the provider's unambiguous terminal-success status as paid — anything else\nstays pending — so a not-yet-settled payment can never be recorded as captured.\n\nAlso fixed: the webhook dispatcher now handles the `charge.succeeded` event class\n(not only `intent.succeeded`), so Xendit and dLocal success webhooks record a\ncharge directly. Paystack and Midtrans (whose webhook payload id isn't the stored\nintent id) record via the new verification path until per-provider webhook\nmatching is added.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.585Z","updatedAt":"2026-06-05T01:29:13.585Z"},{"id":"271139a4-14a6-49f9-a51f-06d769800ad8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-datatable-cell-nav","type":"added","scope":"ui","summary":"DataTable gains opt-in spreadsheet-style keyboard cell navigation.","body":"Added an opt-in `enableCellNavigation` mode to the `DataTable`. When enabled, the\ngrid behaves like a spreadsheet for keyboard users: a roving cell focus moves with\nthe **arrow keys** (Home/End jump to the row's first/last cell), and **Enter / F2**\nactivates the focused cell's control — beginning an inline edit, opening a\nselect/date popover, or toggling a checkbox. The active cell shows the standard\nfocus ring.\n\nOpt-in and backward-compatible: default tables are untouched. When cell navigation\nis on, per-row `onRowClick` is ignored (cells own the interaction). Phase 2 of the\nDataTable modernization.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.238Z","updatedAt":"2026-06-05T01:29:15.238Z"},{"id":"3f0025c7-8edd-41cb-8066-4b963214e88f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"expenses-datatable","type":"changed","scope":"expenses","summary":"Expenses' Categories, My-expenses, and Approval queues now use the unified DataTable.","body":"Three Expenses surfaces moved from hand-rolled `<ul>` lists to the shared DataTable:\n\n- **Categories**: color swatch + name (with code / deductible / receipt / inactive\n  tags), GL / cap, 30-day spend, and Edit / Delete in a row menu.\n- **My expenses**: date, description (with merchant / category and rejection\n  reason), status, amount, and the status-aware Edit / Submit / Delete row menu.\n- **Approvals**: the Reports and Standalone-expenses review queues, each with their\n  always-visible Reject / Approve buttons preserved.\n\nAll gain sortable columns, a search box, the polished hover styling, and remembered\nview prefs (`tableId`). Create / edit / reject dialogs are unchanged. Part of the\napp-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.681Z","updatedAt":"2026-06-05T01:29:15.681Z"},{"id":"07979bcd-73f4-4712-8df4-3477dee7f9ae","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-return-page-marks-invoice-paid","type":"fixed","scope":"payments","summary":"Paying on the return page now actually marks the invoice paid (events were being dropped).","body":"When a buyer completed payment and landed on the return page, the page correctly\nshowed \"Payment received\" but the invoice stayed unpaid. The confirmation runs in\nan anonymous (no-login) request context whose event bus is a no-op, so the\ninternal `charge.succeeded` / `payment.succeeded` events that mark the invoice\npaid were silently discarded — the charge was recorded but nothing downstream\nreacted.\n\nPayment recording (and the matching session-confirmed signal that completes\nsignup) now emit through a dedicated system event bus scoped to the paying\norganization, independent of who triggered the confirmation. Whichever path\nrecords first — webhook, return page, or the reconciliation sweep — now reliably\nmarks the invoice paid.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.379Z","updatedAt":"2026-06-05T01:29:13.379Z"},{"id":"88dab632-323e-4411-9375-adbe8bf64c6b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"projects-spawn-client-link","type":"changed","scope":"projects","summary":"Projects auto-created from an accepted quote or a won deal are now linked to that client.","body":"Closed part of the SURF-4 \"every project has a client\" gap for automated projects.\nTemplate instantiation (`projects.template.instantiate`) now accepts an optional\n`clientId`, and the subscribers that spawn delivery projects pass the originating\nclient through — both when a **quotation is accepted** (the quote's company) and\nwhen a **deal is won** (the deal's company, now carried on the\n`crm.deal.stage_changed` event). So an auto-created project lands already connected\nto the right client instead of unassigned. Onboarding projects (internal, no\nexternal client) stay unlinked by design.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.871Z","updatedAt":"2026-06-05T01:29:14.871Z"},{"id":"cb547d58-8623-4b5d-8755-5d7194665fba","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-datatable-review-fixes","type":"fixed","scope":"ui","summary":"Hardened the new DataTable cell-navigation and persistence (SSR-safe, ARIA grid roles, sturdier keyboard activation).","body":"Follow-up hardening on the DataTable modernization, from an adversarial review and\nverified by mounting the real table in a headless browser:\n\n- **SSR-safe persistence** — `tableId` prefs now seed from defaults and apply\n  *after mount* (no localStorage read during render), so an SSR'd table with a\n  `tableId` no longer risks a hydration mismatch; the first write is skipped so\n  defaults never clobber a freshly-read value.\n- **Cell navigation can't get stuck** — the active cell is clamped to the current\n  bounds on every key press, so navigation still works after filtering/paging to\n  fewer rows.\n- **ARIA grid semantics** — in cell-navigation mode the table now exposes\n  `role=\"grid\"` / `row` / `gridcell` / `columnheader` (gated to the opt-in, so\n  plain tables keep native semantics) for assistive tech.\n- **Sturdier Enter/F2 activation** — defers to a control that already has focus\n  (no double-toggle) and clicks checkboxes/radios rather than just focusing them.\n- **Visible mouse cursor** — the active cell shows an inset accent ring (not just\n  a tint), and the cursor only paints after a real interaction.\n- **EditableDateCell** now stops propagation, so opening the calendar in a\n  clickable row no longer also triggers the row click.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.359Z","updatedAt":"2026-06-05T01:29:15.359Z"},{"id":"77cf696a-a26a-4356-92ae-5dcf29fb5dc1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-datatable-sticky-persist","type":"added","scope":"ui","summary":"DataTable can pin its header over a scrolling body and remembers density / columns / page size per table.","body":"Two opt-in DataTable upgrades:\n\n- **Pinned header + scroll container** — pass `maxBodyHeight` and the table body\n  scrolls within that height while the (frosted) header stays pinned, so column\n  headers stay visible in long tables. Omit it for today's full-height layout.\n- **Preference persistence** — pass a `tableId` and the table remembers the user's\n  **density**, **column visibility**, and **page size** across reloads\n  (localStorage, seeded on mount so there's no flash). No persistence unless a\n  `tableId` is given.\n\nBoth backward-compatible. Phases 3–4 of the DataTable modernization.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.402Z","updatedAt":"2026-06-05T01:29:15.402Z"},{"id":"5f50e24d-7f55-455b-bf42-264100742ff7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-visitor-origin-region-methods","type":"added","scope":"payments","summary":"Checkout detects the payer's country to tailor payment methods to their region.","body":"The public checkout page now detects the payer's country from their connection\n(via the CDN geo header) and uses it to seed Stripe's billing country, so the\npayment methods shown are tailored to the payer's region (alongside Stripe's\nautomatic-payment-methods, which already adapts to currency + the methods the\noperator enabled). The page also shows a small \"Paying from 🇩🇪 Germany\" line so\nthe payer knows their region was detected. Falls back cleanly (no banner, Stripe's\nown detection) when no country is available, e.g. local development.\n\nThe detected country is exposed to actions as `ctx.requestCountry` — a\npresentation hint only; it is never stored and never used for authorization.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:13.833Z","updatedAt":"2026-06-05T01:29:13.833Z"},{"id":"b24d2f24-6695-44ce-9e88-7c856ed85129","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-garnishments-datatable","type":"changed","scope":"payroll","summary":"Garnishments table now uses the unified DataTable — sortable columns, search, and remembered view prefs.","body":"The Payroll › Garnishments table moved from a hand-rolled HTML table to the\nshared DataTable: same columns (employee, kind, amount, max/run, paid-to-date\nprogress, court ref, period, status), now with sortable columns, a search box,\nthe polished hover styling, and remembered density / column / page-size prefs.\nThe summary stat cards above are unchanged. Part of the app-wide table\nstandardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.499Z","updatedAt":"2026-06-05T01:29:14.499Z"},{"id":"bb6defd7-2b8e-4de9-8435-dc0fbd147019","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-more-primitives","type":"added","scope":"ui","summary":"Three more shared UI primitives — Slider, Banner, and Collapsible.","body":"Continued filling gaps in the shared component library (`@helios/ui`), all\nmatching the design system and keyboard-accessible, no new dependencies:\n\n- **Slider** — range input on a native `<input type=\"range\">` with a themed\n  track + thumb (filled portion driven by a CSS custom property, thumb inherits\n  the canonical focus ring) and an optional value readout.\n- **Banner** — full-width, edge-to-edge announcement bar for app-wide notices\n  (trial expiring, scheduled maintenance, plan limits); the page-level companion\n  to the inline `Alert`.\n- **Collapsible** — a single accessible disclosure (one trigger + one panel), the\n  one-off counterpart to `Accordion`; collapsed content leaves the tab order via\n  `inert` and the panel animates open with the grid-rows reveal.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.416Z","updatedAt":"2026-06-05T01:29:15.416Z"},{"id":"688cee2a-32af-4ac4-a5f8-34c33023b215","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"creditnote-quotation-table-polish","type":"changed","scope":"sales","summary":"Credit-note and quotation detail line-item tables get the same zebra striping as invoices; the quotation tax row is hidden when there's no tax.","body":"Carried the invoice line-item polish across to the credit-note and quotation detail screens for a consistent feel: line rows now have **zebra striping**, and the quotation totals only show the **Tax** row when the quotation actually carries tax (matching the invoice and credit-note behaviour). Presentational only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.669Z","updatedAt":"2026-06-05T01:29:15.669Z"},{"id":"ee1343b5-baf4-4328-a7d0-9c06b85b867c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-schedules-recurring-datatable","type":"changed","scope":"payroll","summary":"Payroll Schedules and Recurring-items tables now use the unified DataTable — search, sortable columns, remembered prefs.","body":"Two more payroll surfaces moved to the shared DataTable:\n\n- **Schedules › Pay schedules**: name, frequency, anchor, payday offset, timezone,\n  with Generate-periods / Edit / Archive / Delete folded into a consistent row\n  menu. The \"Pay groups\" card grid below is unchanged.\n- **Recurring items**: employee, item, amount, cadence, paid-to-date (with the\n  lifetime-cap progress bar), period, and status, with Edit / Archive / Delete in\n  the row menu. The All / Active / Earnings / Deductions filter chips now live in\n  the DataTable filter slot; the KPI stat strip is unchanged.\n\nBoth gain a search box, sortable columns, the polished hover styling, and\nremembered view prefs (`tableId`). Create/edit dialogs untouched. Part of the\napp-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.558Z","updatedAt":"2026-06-05T01:29:14.558Z"},{"id":"75f41719-9e3f-4d25-ab48-b1e5e1e84293","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-project-files-pagination","type":"fixed","scope":"projects","summary":"Project client-file lists (client portal + operator panel) are now paginated instead of capped at 200.","body":"The shared project client-file list fetched a hard-capped 200 files with no way\nto see more — on both the client portal and the operator-side panel. It now uses\nkeyset cursor pagination (newest first) with a **Load more** button on both\nsurfaces, so a project with a long file history shows everything. Messages-thread\npagination (oldest-first, needs a \"load older\" UX) is tracked separately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.811Z","updatedAt":"2026-06-05T01:29:14.811Z"},{"id":"255b4cc0-833f-46a7-a946-a1dc34b1fca1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-colored-borders","type":"fixed","scope":"ui","summary":"Colored and hover/focus borders now render — a layering bug had silently flattened them to the default hairline.","body":"The app-wide default border-color rule was unlayered, and in Tailwind v4 an\nunlayered rule overrides every layered utility regardless of specificity — so\n`border-[color]`, `hover:border-*`, and `focus:border-*` utilities across the app\nwere silently rendering as the plain default hairline instead of their intended\ncolor. Moving that default into Tailwind's `base` layer lets those utilities win.\n\nUser-visible effect: hover states on buttons and cards, tinted badge borders, and\nfield hover/focus borders now actually show the colors the components were written\nto use. Bare (uncolored) borders are unchanged. Verified in light and dark themes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.151Z","updatedAt":"2026-06-05T01:29:15.151Z"},{"id":"6551953f-382e-453e-9df8-8b06e99f51e8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-core-lists-datatable","type":"changed","scope":"payroll","summary":"Payroll Runs, Payslips, My-payroll, and Pay-group member lists now use the unified DataTable.","body":"Four more payroll surfaces moved to the shared DataTable:\n\n- **Runs** (`/payroll/runs`): the runs list, with its URL-persisted status / pay-group /\n  kind / search filters preserved (search box bound to the URL, filter selects in the\n  table's filter slot) and the delete-draft action in a row menu.\n- **Payslips** (`/payroll/payslips`): the payslips index, URL filters (status / group /\n  payday range / search) preserved the same way.\n- **My payroll** (`/payroll/me`): the personal payslip history table (the reimbursement\n  claims card list stays as a lightweight tracker).\n- **Pay group detail**: the active-members grid (with the End-membership action and\n  \"+ Add members\" in the toolbar); the small read-only past-members table was lifted to\n  the shared Table primitive for consistent styling.\n\nAll gain sortable columns, the polished hover/density styling, and remembered view prefs\n(`tableId`); KPI stat strips, dialogs, and the member picker are unchanged. Part of the\napp-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.563Z","updatedAt":"2026-06-05T01:29:14.563Z"},{"id":"e51c8937-6b92-426e-94c2-0dd0f8b86ee9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-projects-pagination","type":"fixed","scope":"projects","summary":"The client-portal Projects list is now paginated instead of capped at 100.","body":"The client-portal **Projects** list fetched a hard-capped 100 projects with no\nway to see more. It now uses keyset cursor pagination (newest first, on\n`createdAt`/`id`) with a **Load more** button, so a client with a long delivery\nhistory sees all of their projects.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.828Z","updatedAt":"2026-06-05T01:29:14.828Z"},{"id":"e7f4beec-e7ea-4c17-958e-51850452295f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-projects-surface-visibility-gate","type":"security","scope":"projects","summary":"Client-portal project data now respects the per-contact \"projects\" surface visibility toggle.","body":"The client-portal project actions (project list, detail, milestone approval,\nclient messages, and client file share) did not honour the per-contact portal\n**surface visibility** toggle the way invoices, quotes, and documents already\ndid. A client whose operator had hidden the \"projects\" surface could still read\ntheir projects, milestones, messages, and files through the portal API.\n\nAll nine project-portal actions now resolve scope through a shared helper that\nenforces the `projects` surface gate (mirroring the clients-module\n`isSurfaceVisible` check, inlined to respect the module boundary): a contact\nwith `projects` hidden gets `policy_denied` instead of project data. Company\nscoping (a client only ever sees their own company's projects) was already\nenforced and is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.847Z","updatedAt":"2026-06-05T01:29:14.847Z"},{"id":"09924f79-5ce1-4e7f-a707-d4a069a9a1ca","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"public-checkout-no-login-required","type":"fixed","scope":"web","summary":"Paying an invoice from a share link no longer forces the payer to log in first.","body":"Anyone with an invoice's pay link can now complete payment without an account.\nThe public checkout, payment-return, and customer-portal pages under `/pay/*`\nwere missing from the public-route allowlist, so clicking \"Pay\" on a shared\ninvoice bounced the payer to the sign-in page. `/pay/*` is now treated as public\n(the session id / token is the auth) both in the app's route guard and the\nserver's host-surface gate.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:14.978Z","updatedAt":"2026-06-05T01:29:14.978Z"},{"id":"0c053c74-788c-4897-9052-f4295941990e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-subscriptions-datatable","type":"changed","scope":"sales","summary":"Subscriptions list now uses the unified DataTable — sortable columns, search, status filter, and remembered prefs.","body":"The Sales › Subscriptions list moved from a hand-rolled `<ul>` record list to the\nshared DataTable. Same info (subscription name + client/interval/start, cycle\ntotal + next run, status, and the Pause/Resume/Cancel row menu) and the same\nstatus filter, now with sortable columns, a search box, the polished hover styling,\nand remembered density / column / page-size prefs (`tableId`). The create sheet and\ncancel modal are unchanged. Part of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.138Z","updatedAt":"2026-06-05T01:29:15.138Z"},{"id":"1c959c88-893f-4b6a-b118-f967fc218f2c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-datatable-inline-editors","type":"added","scope":"ui","summary":"DataTable gains four more inline cell editors — number, date, checkbox, and multi-select.","body":"Inline editing in the `DataTable` was limited to text and single-select. Added\nfour more edit-in-place cell types (in `@helios/ui`), each following the existing\neditable-cell pattern (commit on Enter/blur, Esc cancels, edits never trigger the\nrow click, disabled = plain value):\n\n- **EditableNumberCell** — right-aligned, tabular figures, optional `min`/`max`/\n  `step`/`precision` and a custom formatter (e.g. currency); commits a number or\n  null when cleared.\n- **EditableDateCell** — opens the new calendar DatePicker inline; commits a Date.\n- **EditableCheckboxCell** — inline boolean toggle (commits immediately).\n- **EditableMultiSelectCell** — multi-select via a checkable menu; toggle several\n  values in one pass; commits a string array.\n\nPurely additive — existing tables are unchanged. First step of the DataTable\nmodernization (spreadsheet keyboard navigation, sticky header, and persistence\nfollow).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.318Z","updatedAt":"2026-06-05T01:29:15.318Z"},{"id":"fe700ee3-886c-4d8b-9c3f-bacbdfd73f35","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-form-micro-interactions","type":"changed","scope":"ui","summary":"Form controls gained tactile micro-interactions — animated checkbox/radio marks, press feedback, slider grab-scale.","body":"Subtle, reduced-motion-safe micro-interactions on the form primitives so they\nfeel physical rather than flat:\n\n- **Checkbox** — the tick now pops in with a brief scale overshoot, and the box\n  squishes slightly on press.\n- **Radio** — the dot scales + fades in (a tactile \"fill\") and the ring squishes\n  on press.\n- **Slider** — the thumb grows on hover and a touch more while dragging (grab\n  feel).\n\nAll degrade to instant under `prefers-reduced-motion`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.413Z","updatedAt":"2026-06-05T01:29:15.413Z"},{"id":"3c035ca0-9738-4297-a708-022ca6559349","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-collections-datatable","type":"changed","scope":"sales","summary":"Collections' top-debtors table now uses the unified DataTable — sortable columns, search, multi-select bulk reminders.","body":"The \"Top debtors\" table on Sales › Collections moved from a hand-rolled HTML\ntable to the shared DataTable. Same columns (customer, open invoices, oldest\noverdue, total due) and the same per-row \"Remind\" plus multi-select \"Remind all\nselected\" fanout, now with sortable columns, a search box, the polished hover\nstyling, and remembered view prefs (`tableId`). The currency snapshot picker,\nKPI cells, and aging-bucket strip are unchanged; the table is keyed by currency\nso the selection resets cleanly when the snapshot currency changes. Part of the\napp-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.109Z","updatedAt":"2026-06-05T01:29:15.109Z"},{"id":"032f9277-2269-48ee-9948-ea0de039477f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-settings-loading-error-states","type":"fixed","scope":"sales","summary":"Sales settings page now shows a loading skeleton and an error state, and the Save button shows a spinner.","body":"The Sales → Settings page previously rendered its form immediately with default\nvalues while settings were still loading, and showed that same blank-defaults\nform (never synced) if the load failed. It now shows a layout-matching loading\nskeleton on first load, a clear error message if settings can't be loaded, and\nthe Save button uses the standard spinner while saving.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.135Z","updatedAt":"2026-06-05T01:29:15.135Z"},{"id":"8597a32e-1aee-4c3d-bd63-bfb006f7b231","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-native-selects-to-picker","type":"changed","scope":"sales","summary":"Replaced the remaining native dropdowns across the Sales module with the polished, searchable Picker for a consistent look and feel.","body":"The Sales screens still used a handful of raw browser `<select>` dropdowns, which looked out of place next to the polished `Picker` used everywhere else in the app (and the design system mandates the shared primitive). All 15 were swapped to `Picker` — keyboard-navigable, searchable when the list is long, and visually consistent with the rest of Helios:\n\n- **Invoice detail** — payment method, share-link expiry, credit-note reason, and refund reason.\n- **Credit notes** — the status filter and the \"apply to invoice\" picker (now searchable across open invoices).\n- **Subscriptions** — status filter, cancel reason, and billing cadence.\n- **Recurring templates** — client picker (searchable) and interval unit.\n- **Sales settings** — late-fee type and cadence.\n- **Quotation detail** — share-link expiry.\n\nBehaviour is unchanged (same values, same form guards); this is purely a UI consistency upgrade.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.136Z","updatedAt":"2026-06-05T01:29:15.136Z"},{"id":"06d1b226-8c96-4b74-9ccf-00250a9830a4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-datatable-polish","type":"changed","scope":"ui","summary":"DataTable polish — selected rows get an accent edge, and the cell-nav cursor is always visible.","body":"Visual polish on the DataTable:\n\n- **Selected rows** now carry a crisp accent left-edge (in addition to the tint),\n  so the current selection reads at a glance — now that colored borders render.\n- In cell-navigation mode, the **active cell** shows a persistent accent tint (a\n  spreadsheet-style cursor) on top of the keyboard focus ring, so it's always\n  clear where you are, by mouse or keyboard.\n\nPhase 5 of the DataTable modernization. (Opt-in column resize was scoped out of\nthis pass — it changes the table's layout model and warrants its own increment.)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.346Z","updatedAt":"2026-06-05T01:29:15.346Z"},{"id":"a6894b45-92e9-49f0-84f8-d989cf02038d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-datepicker","type":"added","scope":"ui","summary":"New DatePicker primitive — accessible calendar in a popover, keyboard-navigable, no date library.","body":"Added a **DatePicker** to the shared library (`@helios/ui`) — a single-date\ncalendar that opens in the frosted Popover surface. Fully keyboard-accessible\nvia the ARIA grid pattern: arrow keys move day-to-day and week-to-week (rolling\nacross month boundaries), PageUp/PageDown change month, Home/End jump to the week\nedges, Enter/Space selects, Esc closes, with roving tab-stop focus. Supports\n`min`/`max` bounds, marks today and the selected day, and formats the trigger\nlabel + weekday letters via `Intl` for locale awareness.\n\nDependency-free — no date library; built on the existing Popover and IconButton\nprimitives. Controlled via `value` + `onValueChange`. Calendar date-math verified\nagainst 29 invariants (grid coverage, leap years, month/year rollover).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.404Z","updatedAt":"2026-06-05T01:29:15.404Z"},{"id":"8836e0d4-d7db-42a0-bb92-167133f2b7bc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"auto-invoice-approval-gate-retry","type":"fixed","scope":"sales","summary":"Pay-at-signature auto-invoicing now respects the approval gate and retries transient failures instead of orphaning the quote.","body":"Hardened the pay-at-signature (ONB-2) auto-invoice subscriber, from the audit:\n\n- **Respects the approval gate.** When a client accepts a quote whose total is\n  at-or-above the org's invoice approval threshold, the auto-created invoice is\n  now routed to the approver queue (`sales.invoice.request_approval`, which\n  notifies approvers) instead of being silently auto-issued — acceptance no\n  longer bypasses the approval the org configured. Below-threshold quotes still\n  issue immediately.\n- **Retries transient failures.** If the convert step fails transiently (e.g.\n  invoice-number allocation exhausted, a DB blip) the subscriber now throws so\n  the outbox dispatcher retries, instead of swallowing the error and leaving an\n  accepted quote with no invoice. Permanent failures (quote no longer accepted,\n  or deleted) are still skipped without a retry.\n- Added integration coverage for the approval-gate path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.571Z","updatedAt":"2026-06-05T01:29:15.571Z"},{"id":"fda5c14a-4a2e-4a14-86d9-f552e19b1065","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"settings-currencies-datatable","type":"changed","scope":"web","summary":"Currencies settings — org-currencies and exchange-rate tables now use the unified DataTable; providers table restyled.","body":"Settings › Currencies moved its tables to the shared table primitives:\n\n- **Org currencies** and **Latest rate per pair** are now full DataTables — sortable\n  columns, a search box, polished hover styling, and remembered view prefs\n  (`tableId`); Make-default / Remove and the per-rate Remove moved into row menus,\n  and the currencies \"Seed top 10\" action now lives on the empty state.\n- **Rate providers** (a read-only sync-status monitor) was lifted to the shared\n  Table primitive for consistent header / divider styling.\n\nKPI strip, the ISO-catalog add grid, and the live/manual rate forms are unchanged.\nPart of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.934Z","updatedAt":"2026-06-05T01:29:15.934Z"},{"id":"6450d4b0-369d-4194-9c32-2b9f2d6430be","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"website-editor-polish-round-4","type":"changed","scope":"web","summary":"Page-builder polish round 4 — Sections label matches Field primitive, \"(N)\" section counter, Meta JSON textarea grows from 4 to 8 rows.","body":"Two small but visible improvements to the editor form at\n`/saas/website/$id`.\n\n### 1. Sections label normalized to match Field primitive\n\nThe \"Sections\" section of the form used a hand-rolled `<label>`\nwith `text-sm font-medium` (14px) — but every other field label\nin the form is rendered through the Field primitive\n(`packages/ui/src/primitives/field.tsx`) at 11px\n(`text-[11px] font-medium`). The Sections label loomed 30%\nlarger than every other label and broke the form's vertical\nrhythm at scroll time.\n\nNormalized:\n- Label rendered at the same 11px weight + color tokens as\n  Field's default label\n- Required asterisk styled with the same `var(--color-danger-500)`\n  + `aria-hidden=\"true\"` treatment the primitive uses\n- Added a `(N)` section counter next to the label, in muted\n  color so the count reads as metadata rather than competing\n  with the label text\n\nVisual rhythm now reads cleanly: every label is the same size,\nthe section counter sits where the eye lands.\n\n### 2. Meta JSON textarea grows from 4 to 8 rows\n\nThe Meta (JSON) textarea was `rows={4}` — operators reaching\nfor the raw JSON do so precisely when the structured panels\n(AllowedBlockTypes chips, BlogMetaPanel) don't cover their\ncase, which means they're typically writing 6-12 lines of JSON\nand hitting horizontal scrollbars eating every line.\n\nBumped to `rows={8}` so the common edit case fits without\nscrolling. Operators with longer payloads still benefit from\nthe existing textarea auto-resize behaviour on focus.\n\nPure visual polish; no schema, no actions, no permissions, no\nbehaviour changes. Web typecheck clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.484Z","updatedAt":"2026-06-05T01:29:15.484Z"},{"id":"060a1b68-e7a5-4caa-9236-b043bda71673","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-balance-due-card","type":"changed","scope":"sales","summary":"The invoice detail's balance-due figure is now a tinted highlight card (amber when owed, green when settled), matching the PDF.","body":"The balance-due line in the invoice detail totals block was a plain bold row. It's now a tinted highlight card — amber when there's a balance to collect, a calm green/subtle \"Balance · paid\" when settled — so the eye lands cleanly on the bottom-line number, matching the balance card on the generated invoice PDF. Includes any applied late fees, as before. Presentational only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.737Z","updatedAt":"2026-06-05T01:29:15.737Z"},{"id":"b2ac5d78-cc9f-433d-b17b-2db6603b15a5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"settings-saas-datatable-1","type":"changed","scope":"web","summary":"HRM onboarding-requirements and website-redirects admin tables now use the unified DataTable.","body":"Two more admin tables moved to the shared DataTable:\n\n- **Settings › HRM › Onboarding requirements**: the requirements table (order, kind,\n  label, required, active) with Edit / Delete in a row menu; the \"running on\n  built-in defaults\" fallback (with its \"Copy defaults into table\" action) now renders\n  as the table's empty state, and the intro/help card is unchanged.\n- **SaaS › Website › Redirects**: the redirects table (from, to, type, state, notes)\n  with Edit / Delete in a row menu; the URL-persisted server-side search + enabled/\n  disabled filter are preserved (search bound to the table's search box, filter in the\n  filter slot).\n\nBoth gain sortable columns, the polished hover styling, and remembered view prefs\n(`tableId`). Part of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:15.936Z","updatedAt":"2026-06-05T01:29:15.936Z"},{"id":"f02848c1-f9f9-4def-93cc-cc36838c403f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"creditnote-remaining-card","type":"changed","scope":"sales","summary":"The credit-note detail shows remaining credit as a tinted highlight card (green when available, subtle when fully applied).","body":"Mirrored the invoice balance-due highlight card on the credit-note detail: the residual credit is now a tinted card — green \"Credit available\" while there's unapplied credit, a calm \"Credit · fully applied\" once it's all been applied to invoices — instead of a plain bold row. Matches the remaining-credit card on the credit-note PDF. Presentational only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:45:50.663Z","updatedAt":"2026-06-05T01:45:50.663Z"},{"id":"9281e0b8-988a-4104-98a4-c4dd0b3e1df6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"engagements-datatable","type":"changed","scope":"clients","summary":"The Engagements list now uses the unified DataTable — sortable columns, search, filters, row-click to detail.","body":"The Engagements index moved from a hand-rolled `<ul>` to the shared DataTable:\nengagement (type icon + name + status), client (link + type/billing/renewal), and\nvalue (MRR + TCV). Name/client/tag search and the URL-persisted type/status filters\nare preserved (search bound to the table's box, selects in the filter slot), the\nwhole row navigates to the engagement, and remembered view prefs (`tableId`) are\nadded. The \"Renewing in the next 30 days\" preview widget and the create sheet are\nunchanged. Part of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:45:50.959Z","updatedAt":"2026-06-05T01:45:50.959Z"},{"id":"47bc6973-8b49-42e0-abf5-174001ed20d1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-changelog-polish-filter-chips-active-toc","type":"changed","scope":"marketing","summary":"Marketing /changelog gets type filter chips, month-grouped sidebar TOC, reading-position pill, back-to-top button, and active-TOC highlight on scroll.","body":"Follow-up polish on the collapsible-weeks redesign:\n\n- **Type filter chip bar** sticky beneath the site header. Single-select\n  per type with a live `n entries` counter; flips colour pills to match\n  the entry colour family they filter for. Narrowing auto-expands every\n  release so matches are visible.\n- **Sidebar TOC grouped by month** — once history grows past one month\n  the flat date list loses orientation. Each month header carries a\n  release count.\n- **Active-TOC highlight on scroll** — IntersectionObserver tracks the\n  release header crossing the viewport's upper third; the matching\n  sidebar entry gains a primary-coloured border + bg.\n- **Reading-position floating pill** — fades in after 600 px of scroll\n  showing the active release's tag + title. Mobile-friendly substitute\n  for the desktop-only sidebar.\n- **Back-to-top floating button** — fades in past 800 px, smooth-scroll\n  to top.\n- Inline script grew to 6 sections — still pure DOM API, no framework\n  runtime, with passive scroll listeners + `IntersectionObserver` for\n  the live behaviour.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:45:51.208Z","updatedAt":"2026-06-05T01:45:51.208Z"},{"id":"bb916f75-ca02-48f6-a2d3-b2a1a53e9290","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"marketing-changelog-collapsible-weeks-redesign","type":"changed","scope":"marketing","summary":"Public marketing /changelog redesigned — collapsible weeks (default closed, latest open), hero metrics, per-week type breakdown, semantic colours, sidebar TOC.","body":"The marketing-site `/changelog` page used to render every weekly\nrelease fully expanded — fine at 5 releases, a 1500-row scroll at\n8 weeks × 950+ entries.\n\nThe redesign uses native `<details>` so the page is one giant\naccordion the visitor scrolls through:\n\n- Each weekly release is collapsed by default; the latest is `open`\n  on first paint so visitors see the freshest ship without scrolling.\n- The header always shows tag, ISO-week label (`Week 23 · 2026`),\n  publish date, entry count, and a **per-week breakdown strip** of\n  type pills (Added · Changed · Fixed · …) so collapsed weeks still\n  convey what shipped without expanding them.\n- Hero adds four metric tiles — Releases · Entries · Breaking ·\n  Last shipped — so the cadence is felt at a glance.\n- Each entry within an expanded week now uses semantic colour for\n  the type group header + a left dot in the matching family.\n- Desktop gets a sticky sidebar TOC listing every release with\n  date + entry count, plus **Expand all** / **Collapse all** buttons.\n- 25-line inline script handles auto-expanding on hash navigation\n  (TOC clicks, email-digest deep links) so the browser's scroll\n  anchor always lands on expanded content.\n- Pure SSR — no framework runtime, native `<details>` accessibility,\n  keyboard- and screen-reader-friendly out of the box. Marketing\n  bundle size is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:45:51.219Z","updatedAt":"2026-06-05T01:45:51.219Z"},{"id":"b10dcfad-0f7c-4369-bc54-0e07e2b424f7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-activity-support-tickets","type":"added","scope":"clients","summary":"The client-portal activity feed now includes support tickets (opened / resolved) alongside invoices, quotes, and documents.","body":"The client-portal home activity feed (`clients.portal.activity`) composed\ninvoices, quotations, and documents but left out support entirely. It now\nsurfaces the client's **support tickets** — opened and resolved — each linking\nstraight to the ticket in their portal (`/account/support/$id`), so the feed is\na genuinely unified view of everything happening on their account. The support\nsource is gated by the same per-contact \"support\" surface visibility as the\nrest of the portal, and respects own-scope (a contact restricted to their own\nrecords only sees tickets they personally raised).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:45:51.465Z","updatedAt":"2026-06-05T01:45:51.465Z"},{"id":"1ef0dcaf-5b09-4f9e-963c-2e3345ffb739","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"project-files-uploader-provenance","type":"changed","scope":"projects","summary":"The project client-files panel now shows who shared each file and when, not just an anonymous badge.","body":"On the operator-side project client-files panel, each file row previously showed\nonly a generic \"Client upload\" / \"Shared by you\" badge — so operators couldn't\ntell which teammate shared an operator file. The file list action now joins the\nuploader's name, and the panel shows it alongside the size and date (e.g.\n\"1.2 MB · Jane Doe · Jun 4\"), giving clear provenance for shared files.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:49:06.654Z","updatedAt":"2026-06-05T01:49:06.654Z"},{"id":"2323a652-effe-4c49-8a54-544778ce195e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-link-unfurl-action","type":"added","scope":"chat","summary":"Added chat.link.unfurl action — SSRF-guarded, stale-while-revalidate-cached OG previews.","body":"External URLs pasted in chat can now render OpenGraph / Twitter Card previews via the\n`chat.link.unfurl` action. Reads from a global `chat_link_unfurls` cache when fresh\n(24h positive TTL, 7d hard) and otherwise dispatches a fetch through `@helios/og-fetch`\nwith full SSRF guards (scheme + metadata-host + private-IP denies, DNS pinning, redirect\nre-validation, content-type allowlist, streaming size cap). Failures persist a negative\ncache row with exponential backoff so a flaky origin can't keep us hot-looping. Renderer\n+ composer wiring lands in follow-up commits.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:49:06.655Z","updatedAt":"2026-06-05T01:49:06.655Z"},{"id":"da415fd4-c669-4a41-becd-9f15a4a1cfcc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"project-messages-thread-pagination","type":"fixed","scope":"projects","summary":"The project client-message thread now pages older messages on demand instead of capping at 500.","body":"The client↔operator project message thread loaded a hard-capped 500 messages\nwith no way to see beyond that. It now loads the most recent messages first and\noffers a **\"Load earlier messages\"** control at the top of the thread (on both\nthe client portal and the operator panel), walking backward through history via\nkeyset pagination. This completes cursor pagination across every portal /\nproject-collaboration list (invoices, quotes, projects, files, and now\nmessages).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:49:06.901Z","updatedAt":"2026-06-05T01:49:06.901Z"},{"id":"5f408fdc-af0d-477a-b40a-f33468072dc9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"recruitment-applications-list-datatable","type":"changed","scope":"recruitment","summary":"The Recruitment applications List view now uses the unified DataTable.","body":"The list (table) view on Recruitment › Applications moved from a hand-rolled table\nto the shared DataTable: candidate (avatar + name + CV + email), job, stage, ATS\nscore, panel rating, experience, applied date, source, and the per-row stage-actions\nmenu. The Newest / Best-match / Top-rated / Oldest sort control sits in the table\ntoolbar (URL-persisted), and the pipeline's shared search now drives the table's\nsearch box. The Kanban board, drag-and-drop, the filter panel, and all stage-action\nforms are unchanged. Part of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:49:06.911Z","updatedAt":"2026-06-05T01:49:06.911Z"},{"id":"49dead0d-2b21-4dfa-a08c-9b27b37abb4c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"auto-invoice-activity-note","type":"added","scope":"sales","summary":"Auto-created invoices now carry a timeline note showing they came from a quote acceptance.","body":"When the pay-at-signature flow auto-creates an invoice from an accepted quote,\nit now records a note on the invoice's activity timeline\n(`auto_invoiced_on_accept`, linking back to the quotation). Operators looking at\nan invoice can see at a glance that it was generated by a client accepting a\nquote, rather than created manually — closing a small audit-trail gap. The note\nis best-effort and never blocks or retries the invoice itself.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:31.434Z","updatedAt":"2026-06-05T02:11:31.434Z"},{"id":"650db0a8-98fc-4083-8292-2453de0207ac","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-unfurl-maintenance-cron","type":"added","scope":"chat","summary":"Added SWR refresh + daily GC cron for the chat link-unfurl cache.","body":"The `chat_link_unfurls` cache now self-maintains via a worker cron:\n\n- Every 2 minutes the worker scans for rows whose stale-time has passed\n  (but hard-expiry hasn't) and refreshes them through the action so users\n  keep seeing the cached card while the new fetch is in flight.\n- Every 24 hours a GC pass deletes rows past `expiresAt + 7d`,\n  permanently-blocked rows that haven't been re-asked in 30d, and\n  orphaned `chat_message_links` joins.\n\nBoth pass through the existing `chat.link.unfurl` action so SSRF guards,\nadvisory locks, and audit logging stay single-sourced. Bounded per-tick\nto keep an unbounded backlog from saturating the worker.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.138Z","updatedAt":"2026-06-05T02:11:32.138Z"},{"id":"7a596ed6-faef-4bea-87be-6a3610c9ea89","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-unfurl-swr-refresh-cron","type":"added","scope":"chat","summary":"Chat link-unfurl cache now stale-while-revalidates every 2 minutes and garbage-collects daily.","body":"Background maintenance for the `chat_link_unfurls` cache:\n\n- **`refreshStaleUnfurls`** — every 2 minutes, scans for rows whose stale-time has passed but hard-expiry hasn't, then re-fetches them via the `chat.link.unfurl` action. Users keep seeing the cached card while the refresh is in flight (SWR semantics). Bounded by `batchSize` (default 50) so a backlog can't saturate the worker.\n- **`gcUnfurls`** — once per day, deletes rows past `expiresAt + 7d`, blocked rows with no asks in 30 days, and orphaned `chat_message_links` join rows.\n\nBoth go through the existing `chat.link.unfurl` action so SSRF guards, advisory locks, and audit logging stay single-sourced — the cron is just a scheduler. Wired into `apps/worker/src/index.ts` via `startChatUnfurlCron`.\n\nEffect: link previews stay fresh without users ever waiting on a fetch, and the cache table doesn't grow unbounded.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["platform-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.138Z","updatedAt":"2026-06-05T02:11:32.138Z"},{"id":"4b0426e2-43c9-4367-8873-ae7296dee829","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"projects-analytics-risk-table","type":"changed","scope":"projects","summary":"The Projects analytics at-risk-tasks table now uses the shared Table primitive.","body":"The \"at-risk tasks\" table on Projects › Analytics (slipping / blocked / stale) now\nrenders through the shared Table primitive for consistent header / divider / hover\nstyling. As a focused analytics-dashboard section it stays a lightweight table; the\ncharts, KPI tiles, velocity series, and the per-assignee workload bars are unchanged.\nPart of the table-consistency pass (Tier 2).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.385Z","updatedAt":"2026-06-05T02:11:32.385Z"},{"id":"1dc120b0-273e-455f-9d68-b5d07b47c13c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-custom-field-builder","type":"added","scope":"crm","summary":"Added a custom-field builder to define extra fields on CRM records.","body":"CRM admins can now define, edit, reorder, and archive custom fields for each\nentity (deals, contacts, companies, leads) from a \"Custom fields\" panel on the\nCRM hub. Supported field types: text, number, date, yes/no, select, and URL.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.476Z","updatedAt":"2026-06-05T02:11:32.476Z"},{"id":"7967b923-4e8a-4017-8121-d621795d684b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-url-extraction-on-post","type":"added","scope":"chat","summary":"Posted chat messages now extract external URLs and queue unfurl previews.","body":"`chat.message.post` now extracts http(s) URLs from the message body (both\nexplicit TipTap link marks and bare URLs in plain text), persists them\ninto `chat_message_links`, and carries them through the\n`chat.message.posted` event. A new `chat.unfurl.dispatch` subscriber fans\nthe URLs out to `chat.link.unfurl` so OG previews land in the cache\nwithout blocking the post.\n\nBounded at 5 URLs per message; duplicates collapse via the canonical\nURL hash so paste-twice-same-link wastes no fetch budget.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.493Z","updatedAt":"2026-06-05T02:11:32.493Z"},{"id":"4a8be49a-79af-4007-9f53-d1762593c4fa","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-custom-fields-on-records","type":"added","scope":"crm","summary":"Custom fields now appear on the deal record, editable inline.","body":"Custom fields defined for deals now render on the deal detail page with inline\nediting, so teams can capture and update their own data points alongside the\nstandard deal fields.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.704Z","updatedAt":"2026-06-05T02:11:32.704Z"},{"id":"95d9716b-a737-4132-9176-5457ee2e53be","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-custom-fields-contact-lead-records","type":"added","scope":"crm","summary":"Custom fields now appear on contact and lead records too, editable inline.","body":"Custom fields defined for contacts and leads now render on their record detail\npages with inline editing, matching the deal record — so custom fields are\nconsistent across deals, contacts, and leads.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:32.715Z","updatedAt":"2026-06-05T02:11:32.715Z"},{"id":"1480876e-c793-46fd-ae2e-6690bf731c08","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-run-detail-tables","type":"changed","scope":"payroll","summary":"The payroll run-detail tables (payslips, disbursements, batches, time preview) now share the unified Table styling.","body":"The payroll run-detail page lifted its four data tables — payslips, disbursements,\ndisbursement batches, and the time-entry preview — to the shared Table primitive for\nconsistent header / divider / hover styling across the page. These are scoped detail\nsub-sections of a single run (the searchable, sortable index lives at /payroll/payslips\nand /payroll/runs), so they stay lightweight rather than gaining full DataTable chrome.\nThe lifecycle controls, approval chain, timeline, and adjustment dialog are unchanged.\nPart of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:33.041Z","updatedAt":"2026-06-05T02:11:33.041Z"},{"id":"410c6d0a-073c-4362-af49-08e91bf946c5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-reports-table-primitive","type":"changed","scope":"sales","summary":"Sales Reports' top-debtors table restyled through the shared Table primitive.","body":"The top-debtors table on Sales › Reports now renders through the shared Table\nprimitive (consistent header, dividers, and hover with the rest of the app). It's a\npre-ranked aggregate summary, so it stays a lightweight table rather than a full\nDataTable. Same data, no behavior change. Part of the table-consistency pass (Tier 2).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:33.042Z","updatedAt":"2026-06-05T02:11:33.042Z"},{"id":"57dba7c5-e49f-4178-98b8-cea9aa5bee3f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-reports-dashboard-tables","type":"changed","scope":"payroll","summary":"Payroll Reports' year-end table uses the unified DataTable; the dashboard's run + currency tables get consistent styling.","body":"- **Payroll › Reports**: the year-end annual-statements table (employee, payslips,\n  gross, tax, net + per-row statement download) moved to the shared DataTable, gaining\n  sortable columns, search, and remembered prefs (`tableId`). The earnings-register\n  KPI strip and the by-pay-group / by-earning-code summaries are unchanged.\n- **Payroll dashboard**: the \"Recent runs\" and multi-currency-totals tables were\n  lifted to the shared Table primitive for consistent header / divider styling (they're\n  curated dashboard previews, so they stay lightweight — the full runs index at\n  /payroll/runs is the DataTable). KPI strip, alerts, and the upcoming-runs card list\n  are unchanged.\n\nPart of the app-wide table standardization pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:11:33.049Z","updatedAt":"2026-06-05T02:11:33.049Z"},{"id":"a8954166-b408-4cf9-be2e-71716d70cbee","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"email-support-dashboards-table-primitive","type":"changed","scope":"web","summary":"Email and support dashboard summary tables now use the shared Table primitive for consistent styling.","body":"Three dashboard summary tables were lifted to the shared Table primitive for\nconsistent header / divider / hover styling:\n\n- **Settings › Email dashboard** and **SaaS › Platform email dashboard**: the\n  \"By event class\" deliverability summary (sent / delivered % / bounce % / complaint %).\n- **Settings › Support reports**: the agent-leaderboard table.\n\nThese are aggregate summaries, so they stay lightweight tables rather than full\nDataTables. KPI stat strips, provider/rate cards, charts, and the print-to-PDF export\n(which builds its own HTML tables) are unchanged. The SaaS module-availability matrix\nwas intentionally left as-is — it's a derived cross-tab comparison grid, not a row list.\nPart of the table-consistency pass (Tier 2).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:13:22.617Z","updatedAt":"2026-06-05T02:13:22.617Z"},{"id":"534b7c75-f0bd-449a-aca6-f367de9456b9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-slash-command-fixes","type":"fixed","scope":"chat","summary":"Fixed the /remind slash command (was calling the wrong action) and made /invite fail gracefully.","body":"Two chat slash-command fixes:\n\n- **/remind** called an action name that doesn't exist (`chat.followup.create`)\n  so setting a reminder via the slash command silently errored. It now calls the\n  correct `chat.message.followup_create` action — reminders work.\n- **/invite &lt;email&gt;** called a `chat.external.invite` action that was never\n  built, throwing a confusing server error. It now shows a clear \"not available\n  yet\" message instead. (Inviting people to a channel by email is tracked as a\n  future feature.)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:30:36.330Z","updatedAt":"2026-06-05T02:30:36.330Z"},{"id":"98327272-75a9-45c9-a508-a26e81370d97","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-unfurl-sister-actions","type":"added","scope":"chat","summary":"Added link-preview hide + cache invalidate + per-message preview list actions.","body":"Three sister actions round out the link-unfurl backend:\n\n- `chat.message.hide_preview` — author can dismiss a preview on their own\n  message without deleting the link. Renderer skips hidden rows.\n- `chat.og_cache.invalidate` — admin-only force-refresh of a single\n  cache row (admins can rebust a preview without waiting for the TTL).\n- `chat.message.list_link_previews` — joined read of `chat_message_links`\n  + `chat_link_unfurls` ordered by document position so the renderer\n  has everything it needs in one round-trip.\n\nTwo new permissions land alongside: `chat:message:hide_preview` (granted\nto every member by default) and `chat:og_cache:invalidate` (granted to\nmanagers + admins + the existing `chat:admin` escape hatch). The React\nrenderer (LinkUnfurlCard / LinkUnfurlChip) ships in the next batch.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:30:37.023Z","updatedAt":"2026-06-05T02:30:37.023Z"},{"id":"fa320489-9c38-4d0f-b84a-27af80354c1d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-detail-statement-table","type":"changed","scope":"clients","summary":"The client-detail statement ledger now uses the shared Table primitive.","body":"The running-balance statement ledger on the client detail page now renders through\nthe shared Table primitive for consistent header / divider styling. It stays a\nlightweight table (it's a printable/emailable statement, not a full DataTable); the\nEmail-statement and Download-PDF actions are unchanged. Concludes the table-consistency\npass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:30:37.039Z","updatedAt":"2026-06-05T02:30:37.039Z"},{"id":"19c48e53-b8db-49ef-ab67-19cda7aa505b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-issue-concurrency-guard","type":"fixed","scope":"sales","summary":"Issuing an invoice is now race-safe — a concurrent issue can no longer double-fire the invoice-issued event.","body":"`sales.invoice.issue` read the invoice's status and then updated it in two\nsteps, so two requests issuing the same draft at once could both proceed — each\nre-emitting `invoice.issued` (double emails / downstream reactions) and re-minting\nthe public share link. The status transition is now an atomic compare-and-set\n(`WHERE status = 'draft'`): if the invoice was already moved out of draft, the\ncall returns a clear conflict instead of silently re-issuing. Matches the guard\nthe bulk-issue action already used.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:30:37.295Z","updatedAt":"2026-06-05T02:30:37.295Z"},{"id":"33100da6-6df4-48a8-8538-667fbfe24b05","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"dispute-amount-bigint","type":"fixed","scope":"payments","summary":"Dispute alert amounts are now computed with exact integer math instead of float division.","body":"The \"dispute opened\" alert formatted the disputed amount with\n`Number(cents) / 100`, which loses precision for very large amounts (above\nJavaScript's safe-integer range) — and violates the module's floats-banned money\nrule. It now uses BigInt division, so the amount shown to the owner is always\nexact.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:30:37.307Z","updatedAt":"2026-06-05T02:30:37.307Z"},{"id":"67be09ae-1ff2-4d27-99cb-c66845c67ace","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-return-records-invoice-inline","type":"fixed","scope":"payments","summary":"The payment page now marks the invoice paid immediately, without waiting on background processing.","body":"When a customer completes payment and the return page shows \"Payment received\",\nthe invoice is now marked paid in that same request — recorded directly, instead\nof relying solely on a background event being delivered by the worker. The\nevent-driven path still runs (and stays the source of truth for other\nconsumers), but the invoice no longer depends on it: the record is applied\nsynchronously and deduplicated on the provider charge id, so whichever path runs\nfirst wins and the other is a clean no-op. This removes the dependency on\nworker/event-delivery for the most important step — actually marking the invoice\npaid.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T02:30:37.643Z","updatedAt":"2026-06-05T02:30:37.643Z"},{"id":"53c2d237-47b7-476b-9e97-5a0b8501c9c3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"activity-reassign-notifies","type":"fixed","scope":"crm","summary":"Reassigning a CRM task/activity now notifies the new assignee — the notification was wired but never fired on update.","body":"Creating an activity assigned to someone notified that person, but **reassigning**\nan existing activity (changing its owner via `crm.activity.update`) silently did\nnot — even though the email flow and the notification subscriber both already\nexisted. `updateActivity` only emitted `crm.activity.updated`; it now also emits\n`crm.activity.assigned` when the owner actually changes to a different (non-empty)\nuser, so the new assignee gets their notification. No emit fires when the owner\nis unchanged or cleared.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:39.657Z","updatedAt":"2026-06-05T21:23:39.657Z"},{"id":"ac5840d5-c405-4a4b-b510-69edf71b095e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contact-lifecycle-engine","type":"added","scope":"crm","summary":"Contact lifecycle moves are governed — going backward needs a reason and is audited.","body":"Contacts now move through their lifecycle funnel (subscriber → lead → MQL → SQL\n→ opportunity → customer → evangelist) through a single governed path. Moving a\ncontact **forward** is instant; moving it **backward** (a regression) requires a\nreason and is recorded as an audited event, so no contact ever gets stuck and\nevery regression is explained. The contact record's Stage control prompts for\nthe reason on a backward move.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:40.278Z","updatedAt":"2026-06-05T21:23:40.278Z"},{"id":"cf05f96b-31cb-43f8-ab50-8ca4ad5217ed","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-polish-tokens-tactile","type":"changed","scope":"ui","summary":"UI polish — tactile press on more controls + hardcoded colors replaced with design tokens.","body":"A round of design-system consistency polish (from a UI audit):\n\n- **Tactile press feedback** added to the Breadcrumb link-button and Pagination page\n  buttons (`active:scale-[0.97]`), matching the canonical Button / IconButton / Toggle\n  press behavior.\n- **Hardcoded Tailwind palette colors replaced with design tokens** so they theme\n  correctly: the subdomain status pills + doc links (neutral/green/red/violet/amber →\n  success/danger/warning/accent tokens), the form-builder remove-rule buttons (red →\n  danger tokens), and the cookie-consent \"Got it\" button (text-white → `--fg-on-accent`,\n  plus a tactile press).\n\nNo behavior change; styling consistency only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:40.535Z","updatedAt":"2026-06-05T21:23:40.535Z"},{"id":"7be01107-c7aa-4df4-9eb7-4f3c4c4356a6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-builder-color-tokens","type":"changed","scope":"forms","summary":"Form-builder UI now uses design tokens instead of hardcoded Tailwind palette colors.","body":"The structured form-builder (the section/field editor, the per-field config/validation\neditor, and the form-level validations editor) had ~100 hardcoded Tailwind palette\ncolor classes (neutral / violet / red / amber). These were replaced with the design\ntokens so the builder themes correctly with the rest of the app: neutrals → `--fg-*` /\n`--bg-*` / `--border-*`, the selected-field accent (stale brand violet) → `--accent`,\nremove/error red → `--color-danger-*`, and the config-error warning callout →\n`--color-warning-*`. No behavior change; styling consistency only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:40.556Z","updatedAt":"2026-06-05T21:23:40.556Z"},{"id":"dcdd4407-a7b1-406d-b5ef-f0cec766132e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"rename-web-dispatcher-unsafe-flag","type":"changed","scope":"infra","summary":"The opt-in web in-process outbox dispatcher flag is renamed to make its danger explicit.","body":"The environment flag that lets the web process drain the events outbox (intended\nonly for worker-less deployments) was named `HELIOS_WEB_INPROCESS_DISPATCHER` —\neasy to mistake for a harmless \"redundancy\" switch. Enabling it alongside the\ndedicated worker is the exact misconfiguration that previously dropped\ncross-module events (payments, subscriptions). It is renamed to\n`HELIOS_WEB_INPROCESS_DISPATCHER_UNSAFE` and now logs a loud error when set, and\nthe dispatcher's subscriber-safety gate refuses to drain in any process that has\nnot registered handlers. Deployments that set the old name must rename it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:40.560Z","updatedAt":"2026-06-05T21:23:40.560Z"},{"id":"f812bf1b-a6a9-40bd-9878-32732de9d293","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"stripe-hosted-checkout-recording","type":"fixed","scope":"payments","summary":"Stripe-hosted invoice payments are now reliably recorded — the invoice is marked paid even with no webhook.","body":"Paying an invoice through a Stripe-hosted checkout could leave the invoice stuck\non \"issued\" with no payment record, even though Stripe showed the charge as\nreceived. Root cause: a hosted checkout is a Stripe **Checkout Session** (`cs_…`),\nand at create time Stripe has not yet minted the underlying PaymentIntent — so\nHelios stored the `cs_…` id as the payment reference. Every recovery path then\nlooked the payment up as a PaymentIntent (`GET /payment_intents/cs_…`), which\n404s, so the charge was never recorded and the invoice never flipped to paid.\n\nThe provider's status check now detects a `cs_…` reference, resolves it to the\nreal PaymentIntent (and its charge) via the Checkout Session, and derives success\nfrom the session's payment status. The \"thank you\" return page records the\npayment inline using this, so an invoice is marked paid the moment the buyer\nlands back — independent of any webhook. A stuck invoice can be recovered by\nreopening its `/pay/return/<id>` link after this ships.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:40.590Z","updatedAt":"2026-06-05T21:23:40.590Z"},{"id":"d3f1a1e5-4474-4f73-9fff-02ee550b1600","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-quotation-decline-status-guard","type":"fixed","scope":"sales","summary":"Declining a quotation now respects its current status — accepted or converted quotes can no longer be silently flipped to declined.","body":"`sales.quotation.decline` previously updated any matching quotation to\n`declined` with no source-status check, so an already **accepted** (possibly\nalready converted to an invoice), **expired**, or **declined** quote could be\nre-declined — corrupting the funnel and firing a spurious \"quotation declined\"\nnotification. Decline now mirrors accept: it's idempotent for an\nalready-declined quote and returns a conflict for any quote that isn't in a\ndeclinable state (`sent` / `viewed` / `draft`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:23:40.723Z","updatedAt":"2026-06-05T21:23:40.723Z"},{"id":"e1dc0f47-a362-40de-b80f-2a416fab5979","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-import-extraction-warning","type":"fixed","scope":"hrm","summary":"Importing HR profile data from a candidate now logs a warning when the parsed-CV data is unreadable, instead of silently importing nothing.","body":"`hrm.employee.import_from_candidate` reads the candidate's AI-parsed CV JSON.\nWhen that data was present but not a readable object, the import silently\nreturned success with zero fields copied — no signal to the operator. It now\nlogs a warning in that case (matching the existing CV-clone-failure log), so a\nzero-field import is observable rather than mysterious.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:33:51.268Z","updatedAt":"2026-06-05T21:33:51.268Z"},{"id":"18e5e210-2845-400f-bdb3-b15723b23fbf","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-list-search-boxes","type":"added","scope":"crm","summary":"The Contacts and Leads list search boxes now search the whole org, server-side.","body":"The search box on the Contacts and Leads lists now queries the server (name,\nemail, phone, company, job title) instead of only filtering the rows already on\nscreen — so it finds matches across every record, not just the first page.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:43:16.612Z","updatedAt":"2026-06-05T21:43:16.612Z"},{"id":"7632dc49-6d09-41ad-89b1-c641c6a9c91f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-list-search","type":"added","scope":"crm","summary":"Contacts, deals, and leads can now be searched by free text, like companies.","body":"The contact, deal, and lead list actions now accept a free-text `query` —\nmatching on name, email, phone, job title, and (for deals) the linked company\nand contact names. This brings them to parity with company search and lets the\nAI assistant find CRM records by typing part of a name or company. List-view\nsearch boxes adopt it next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:33:51.268Z","updatedAt":"2026-06-05T21:33:51.268Z"},{"id":"443bab2b-d5c1-4932-9dc5-819f055ef60d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-late-fee-in-balance-due","type":"fixed","scope":"sales","summary":"Online invoice payments now include any applied late fee — the \"Pay\" button no longer undercharges and leaves a fee outstanding.","body":"Late fees are tracked outside an invoice's `total`, but the balance-due figure\nwas computed as bare `total − paid`, so it **omitted the fee everywhere money is\ncollected**: the public \"Pay now\" link and the client-portal \"Pay\" button both\nminted a checkout for the lesser amount, and the on-screen / recipient balance\ndisagreed with the figure printed on the PDF. A customer could pay in full and\nthe invoice would flip to `paid` while the late fee silently went uncollected.\n\nA single shared `balanceDue(total, paid, lateFee)` helper now backs the invoice\nlist, the invoice detail, the public recipient view, and **both** checkout\namounts (`sales.invoice.public.pay` + `sales.invoice.client_pay`), so the number\ncan't drift between the screen, the PDF, and what the customer is actually\ncharged. The overdue flag stays keyed on principal (a fully-paid invoice with\nonly a fee left is not \"overdue\"), and the PDF is unchanged (it already added\nthe fee itself).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:33:51.562Z","updatedAt":"2026-06-05T21:33:51.562Z"},{"id":"b8254aee-75d5-4ab7-b69d-c4c9f67e6494","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"subscription-enddate-tz","type":"fixed","scope":"sales","summary":"Subscriptions now terminate on the correct day in non-UTC orgs (the end-date check used UTC instead of the org timezone).","body":"When a subscription cycled, the \"has this passed its end date?\" check compared\nthe next run date in **UTC** while the cycle's issue/due dates were computed in\nthe **org's timezone** — so a subscription in a non-UTC org could terminate a day\nearly or late around the boundary. The check now uses the org's calendar day\n(`isoDayInTz`), consistent with the rest of the cycle dates.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:33:51.562Z","updatedAt":"2026-06-05T21:33:51.562Z"},{"id":"7cdf29b3-f0a8-40f1-b177-f10a1839d281","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-refund-receipt-pdfs","type":"changed","scope":"sales","summary":"Payment-receipt emails now attach a proper payment-receipt PDF (not the invoice), and there's a dedicated refund-receipt document.","body":"The \"payment received\" email used to attach the **invoice PDF** with a PAID\nstamp as the \"receipt\" — which meant a refunded payment's document still read\nas a paid invoice. Sales now generates two dedicated receipt documents:\n\n- **Payment receipt** — a standalone acknowledgement of money received (amount,\n  method, reference, paid-on date, invoice reference, remaining balance). The\n  payment-receipt email attaches this instead of the invoice.\n- **Refund receipt** — the matching document for money returned (amount\n  refunded, original method + reference, reason, refunded-on date).\n\nBoth carry the operator's letterhead/branding and are downloadable via the new\n`sales.payment.render_receipt_pdf` / `sales.payment.render_refund_receipt_pdf`\nactions. The refund-receipt email + operator button land next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:43:17.430Z","updatedAt":"2026-06-05T21:43:17.430Z"},{"id":"450a3520-7c39-49eb-8e12-c32eb29398ce","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"recruitment-hrm-color-tokens","type":"changed","scope":"web","summary":"Recruitment/HRM status + role chips and the password-strength meter now use design tokens.","body":"More design-system color consistency — replaced hardcoded Tailwind palette colors with\ntokens across status/role chips that turned out to be semantic (not arbitrary):\n\n- **password-strength meter**: weak/fair/good/strong → `--color-danger/warning/info/success`.\n- **offer-revision timeline** + **HRM document-history** status badges (draft/sent/\n  accepted/declined/voided/…) → the matching semantic tokens; the \"current/leaf\" revision\n  highlight → `--accent`.\n- **job pipeline stats** stage chips (screening/interview/offer/hired/rejected) → semantic\n  tokens (offer → `--accent`).\n- **hiring-team** role chips → distinct themeable semantic-token hues (roles are\n  categorical; the label carries meaning).\n\nNo new token scale was needed — these are semantic, so they map to existing tokens (which\nalso fixes them in dark mode). Intentional-color surfaces (brand preview, AI-panel gradient,\nPDF preview, changelog type badges) were deliberately left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T21:43:17.445Z","updatedAt":"2026-06-05T21:43:17.445Z"},{"id":"655214bf-6909-49ef-be23-e378e6c22aac","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deals-board-search","type":"added","scope":"crm","summary":"The deals board now has a search box to find cards by title, company, or contact.","body":"The Deals pipeline board gained a search box: type to narrow the cards to those\nmatching a deal title, company, or contact name (server-side, across the whole\nboard). The column totals stay org-wide — they're the pipeline overview — and a\n\"no deals match\" hint plus a Clear button make an empty search easy to recover\nfrom.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:09.423Z","updatedAt":"2026-06-06T07:07:09.423Z"},{"id":"4403ec6f-b105-4eda-9aa6-ae38361438a4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-search-hardening","type":"fixed","scope":"crm","summary":"Server-side list search no longer hides matches found on off-screen columns.","body":"Fixed server-side search on the Contacts and Leads lists: a record matched on a\ncolumn the table doesn't show (e.g. a contact found by phone number) was being\nre-hidden by the table's client-side filter, and the result count under-reported\nthe real matches. The data grid now skips its client filter when search runs\nserver-side. Also: the lists no longer double-fetch when opened, and a lead with\nno last name is now found by name.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.134Z","updatedAt":"2026-06-06T07:07:10.134Z"},{"id":"1af4c428-3c16-4489-b951-bfe98969a3b0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"misc-chip-color-tokens","type":"changed","scope":"web","summary":"A few remaining status chips / error texts now use design tokens.","body":"Small design-token cleanups on assorted chrome: the captcha + what's-new-banner error\ntexts (red → `--color-danger-600`), the live-break / on-break time-tracking chips\n(amber → `--color-warning-*`), and the manager-dashboard \"on leave today\" chip\n(violet → `--color-info-*`). Styling consistency only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.146Z","updatedAt":"2026-06-06T07:07:10.146Z"},{"id":"bcc7bedd-896c-44f3-ba9a-9d58f22797a0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"refund-receipt-email","type":"added","scope":"sales","summary":"Refunds can now email the customer a dedicated refund receipt — no more sending a \"paid\" receipt for a refunded payment.","body":"Added `sales.payment.send_refund_receipt`, which emails the customer a refund\nacknowledgement (amount refunded, original method, reason, refunded-on date)\nwith the dedicated **refund-receipt PDF** attached — never the paid-invoice\nreceipt, which previously mislabeled a refunded payment as paid.\n\nThe refund dialog now offers an opt-in \"Email a refund receipt to the client\"\ncheckbox (on by default); on a successful refund it sends the receipt and\nreports success or a soft error if no recipient is on file. Unlike the payment\nreceipt, the refund receipt is never auto-sent — a refund is sometimes an\ninternal reversal the customer shouldn't be emailed about, so the operator\ndecides per-refund. The operator's \"Email receipt to client\" hint now correctly\nsays a receipt PDF (not the invoice) is attached.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.311Z","updatedAt":"2026-06-06T07:07:10.311Z"},{"id":"c9077c49-8dd5-4789-ae6a-f8c770ffe0f4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"route-color-tokens","type":"changed","scope":"web","summary":"~22 app-route screens now use design tokens instead of hardcoded Tailwind palette colors.","body":"Continuing the design-system color consistency pass into the route layer: ~216 hardcoded\nTailwind palette color classes across 22 settings / recruitment / HRM / sales screens were\nreplaced with the design tokens — status banners and chips → `--color-{success,danger,\nwarning,info}-*`, neutral chrome → `--fg-*` / `--bg-*` / `--border-*`, and stale brand-violet\n(selected/active) → `--accent`. These now theme correctly (including dark mode) instead of\nrendering fixed palette colors.\n\nGenuinely categorical or intentional colors were left as-is on purpose (e.g. the HRM\nsettings nav's per-link accent hues, modal-backdrop scrims, white text on saturated\nstatus buttons). Public/auth/onboarding/changelog routes are out of scope for this batch.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.657Z","updatedAt":"2026-06-06T07:07:10.657Z"},{"id":"4344cfb6-4d2b-4fee-b1b3-64bda47abc60","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"route-color-tokens-2","type":"changed","scope":"web","summary":"Remaining app-route status/brand chips now use design tokens.","body":"Second, smaller pass of route-layer color tokenization — the remaining app-chrome screens\n(settings forms/teams/access-security, saas platform-payments, HRM directory/team/offboarding/\none-on-ones/rosters, recruitment talent/compliance, CRM activities). Status chips and stale\nbrand-violet highlights → `--color-*` / `--accent` tokens. Per-shift roster swatch colors\n(operator-chosen, data-driven) and Badge `tone=` props were correctly left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.672Z","updatedAt":"2026-06-06T07:07:10.672Z"},{"id":"0d63d937-9017-4e37-82ea-cddeda5d5800","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"pwa-skip-waiting-auto-update","type":"fixed","scope":"web","summary":"PWA service worker now auto-activates on deploy so fresh code lands on the next navigation instead of being stuck behind a stale precache cache.","body":"After a successful prod deploy (new bundle hash, new precache) the\napp would keep serving the previously-cached HTML shell + `/assets/*`\nbundles until every open tab / installed PWA window was closed and\nreopened. Reason:\n\n- `src/sw.ts` called `clientsClaim()` but NOT `skipWaiting()`, so\n  the new service worker installed correctly and then parked itself\n  in the `waiting` state forever.\n- The old SW kept serving the precached HTML shell (which still\n  pointed at the old bundle hash) and the old `index-*.js` from\n  `helios-assets` (CacheFirst — no network revalidation).\n- The vite-pwa registration was in `prompt` mode with `onNeedRefresh`\n  surfacing a banner, but most users never clicked it.\n\nFixes:\n\n- `src/sw.ts` calls `self.skipWaiting()` so the new SW activates\n  immediately on install, plus a `SKIP_WAITING` message handler as\n  a defensive escape hatch for Safari edge cases.\n- `vite.config.ts` flips `registerType` from `'prompt'` →\n  `'autoUpdate'` so the registerSW chain takes the new SW from\n  install to activate without operator action.\n- `src/main.tsx` adds a single-shot `controllerchange` listener\n  that reloads the page once the new SW claims control — without it\n  the tab keeps executing old in-memory JS even after the SW swap.\n\nNet effect: deploy a new build → next navigation (or open tab's\nnext polling tick, capped at 1h) auto-reloads with the new bundle.\nNo more \"I deployed but nothing changed\" mystery.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.922Z","updatedAt":"2026-06-06T07:07:10.922Z"},{"id":"26827405-9f17-4149-a91b-3408a13513c0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"status-selectors-public-sign-tokens","type":"changed","scope":"web","summary":"Task status toggles, offer prefill box, and the public document-sign page now use design tokens.","body":"Final round of design-token cleanup on app chrome:\n\n- **Task done/in-progress toggles** (inline-status-editor + task-detail-sheet): emerald\n  \"done\" → `--color-success-500`, blue \"in progress\" → `--color-info-500` (filled buttons\n  keep white text; tints/shadows via `color-mix`).\n- **Offer create sheet**: the \"pre-filled from job\" note → accent-tinted box with readable\n  `--fg` text; the unresolved-template-variable chips → `--color-warning-*` (a semantic nudge).\n- **Public document-sign page** (`/h/*`): the \"signed successfully\" confirmation, warning\n  notice, and error box → `--color-success/warning/danger-*` (tokens are already loaded on\n  that public route).\n\nThis completes the component-level color tokenization; the only remaining raw palette colors\nare intentional (brand-color swatches, the AI panel's gradient identity, changelog type\nbadges, the print-mimicking PDF preview).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:07:10.923Z","updatedAt":"2026-06-06T07:07:10.923Z"},{"id":"bd4842d8-6da4-49b2-9def-aba2ae919ad5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"worker-missing-action-registries","type":"fixed","scope":"worker","summary":"Paid invoices are now reliably marked paid — the worker was missing the action registries its subscribers call.","body":"A successful payment recorded its charge but left the invoice stuck on \"issued\"\nwith no payment record. Root cause confirmed on the live server: the background\nworker registered each module's event subscribers, but did NOT load several\nmodules' action registries. So when the `payments.charge.succeeded` subscriber\nran `getAction('sales.payment.record')`, it got `undefined` (\"action not\nregistered\" in the worker log) and silently no-op'd — the invoice was never\nmarked paid. The same gap affected `saas`, `hrm`, `iam`, `clients`, and\n`website` subscribers (e.g. subscription activation after payment).\n\nThe worker now imports the `sales`, `saas`, `hrm`, `iam`, `clients`, and\n`website` action registries alongside the others. A new wiring test asserts that\nevery `getAction('<ns>.…')` namespace used by any subscriber has its registry\nimported in the worker, so this class of silent no-op can't regress.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:30:38.012Z","updatedAt":"2026-06-06T07:30:38.012Z"},{"id":"eba25cdd-8404-49ec-a0f1-4ff76df75f41","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"expenses-transition-cas","type":"fixed","scope":"expenses","summary":"Expense submit/approve/reject/delete are now race-safe — two approvers can no longer both approve the same expense (or double-fire the reimbursement event).","body":"The four expense status transitions read the current status, checked it, then\nissued an id-only UPDATE. Two callers could pass the same check and both write —\ntwo approvers both approving (double `expenses.expense.approved` → double\nreimbursement downstream), an approve racing a reject (both events fire,\nlast-write-wins on status), or a delete swallowing a concurrent submit. Each\nUPDATE now folds the required status (and `org_id` + not-deleted) into its WHERE,\nso the transition is atomic; a 0-row result returns `conflict` and emits no\nevent instead of completing on stale state.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:32:55.938Z","updatedAt":"2026-06-06T07:32:55.938Z"},{"id":"c3693e0e-3bd9-4ee9-ac33-8eb8552f74ef","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-global-search","type":"added","scope":"crm","summary":"⌘K now searches contacts, companies, deals, and leads at once and jumps to the record.","body":"The global Command Center (⌘K) now finds CRM records in one server-side search\nacross contacts, companies, deals, and leads — leads were missing before — and\nopens the matching record directly instead of the list. Results respect what\neach user is allowed to see (per-entity permissions + own/team scope). Powered by\na new `crm.search` action the AI assistant can use too.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:30:38.013Z","updatedAt":"2026-06-06T07:30:38.013Z"},{"id":"ceb114e1-ab2b-425e-b268-d8e5bd59c161","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ai-panel-tokens","type":"changed","scope":"web","summary":"Moved the AI panel's accent colors onto design tokens so they track the active theme.","body":"The AI side panel's message bubbles, no-provider warning, and animated send/typing\ntrails now read from the AI and semantic design tokens instead of hard-coded\nTailwind palette classes, so they stay consistent in both light and dark themes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:30:38.243Z","updatedAt":"2026-06-06T07:30:38.243Z"},{"id":"f331d660-1c24-4c20-9423-ac0af5774650","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"categorical-token-scale","type":"changed","scope":"ui","summary":"Added a theme-aware categorical color scale so nav accents stay legible in dark mode.","body":"Introduced eight theme-aware categorical hue tokens (`--cat-1`…`--cat-8`) with\ndark-mode lightness lifts, and moved the HRM settings navigation icons onto them\nso their colored accents read correctly in both light and dark themes instead of\nwashing out.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T07:30:38.262Z","updatedAt":"2026-06-06T07:30:38.262Z"},{"id":"149d60f0-567c-40e9-a4af-72c9a9094d3d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-unfurl-polish-pass","type":"changed","scope":"chat","summary":"Polish pass on chat link-unfurl — charset hardening, bidi sanitization, actor attribution, tracking-param dedupe.","body":"Second pass from the adversarial-review punch list — eight low-and-\nmedium severity items folded together:\n\n- **Tracking-param regex no longer over-matches.** `ref` / `ref_src` /\n  `spm` were stripping legitimate `referer` / `refresh_token` /\n  `refund_id` / `spmid` params and collapsing distinct URLs onto the\n  same cache key. Split into anchored prefix set + exact-match set.\n- **URL extractor trims trailing punctuation.** Pasted\n  `Visit https://example.com.` no longer cache-fragments into a\n  separate row from `https://example.com`. Trailing `)` is dropped\n  only when the URL has no matching `(` (handles markdown paste).\n- **OG parser strips bidirectional and control codepoints.** A\n  malicious origin could embed U+202E (right-to-left override) in\n  an `og:title` to spoof the rendered site name (e.g. `evil.com`\n  rendered as `moc.live`). React doesn't filter these — we do, in\n  the decoder.\n- **Charset allowlist + `<meta charset>` sniff.** `bytesToString`\n  now rejects attacker-controlled legacy charsets (only utf-8 /\n  latin1 / windows-1252 / utf-16 admitted) and sniffs the document's\n  declared encoding from the first 2 KiB when the Content-Type\n  header offered none. Non-English titles cache correctly.\n- **`</head>` boundary sniff fixed.** The early-stop check missed\n  `</head>` when it straddled chunk boundaries. Now scans the last\n  1 KiB across boundaries instead of the last chunk only.\n- **System-context attributes the originating author.** The\n  `chat.unfurl.dispatch` subscriber passed the anon UUID as the\n  actor, breaking SSRF-block audit attribution. Now uses\n  `envelope.payload.authorId` when present.\n- **`chat.og_cache.invalidate` is `dangerous: true` and returns the\n  URL.** The action triggers an outbound fetch on the next read so\n  it routes through the stricter rate-limit bucket. Audit log now\n  captures the human-readable URL alongside the hash.\n- **User-Agent honours the no-static-branding rule.** Codename\n  fall-back removed — when `BETTER_AUTH_URL` is unset the agent\n  emits `UnfurlBot/1.0` with no contact URL, instead of leaking the\n  codename to every origin.\n\n139 chat tests + 46 og-fetch tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:00.352Z","updatedAt":"2026-06-06T09:02:00.352Z"},{"id":"799943f7-c134-4ed8-a915-b332219176bd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"credit-note-issued-auto-email","type":"fixed","scope":"sales","summary":"Issuing a credit note now automatically emails it to the customer (with the PDF) — it was documented to but never actually fired.","body":"`sales.credit_note.send_email` described itself as \"auto-fired by the\nemail-on-credit-note-issued subscriber when a credit note flips draft → issued\",\nmirroring how invoices auto-send on issue — but that subscriber was never\ncreated, so issuing a credit note emailed the customer nothing unless an\noperator remembered to click Send.\n\nAdded the missing subscriber: issuing a credit note now auto-emails it to the\ncustomer's billing address with the credit-note PDF attached. It reuses the\nexisting send action, so it inherits recipient resolution and a stable\nidempotency key (a re-delivered event never double-sends), and a credit note\nwith no billing email on file simply no-ops instead of erroring.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.147Z","updatedAt":"2026-06-06T09:02:01.147Z"},{"id":"2fa8e3da-e10c-4019-85e6-4faaf358c9c8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-unfurl-security-hardening","type":"security","scope":"chat","summary":"Hardened chat link-unfurl — advisory lock, http(s) scheme allowlist, hide_preview org isolation, external-fetch rate limit, recursion cap.","body":"Post-ship adversarial review surfaced six high-severity issues in the\nchat link-unfurl backend. This bundle addresses them:\n\n- **Advisory lock now actually holds.** `pg_advisory_xact_lock` was\n  issued via `db.execute` outside any transaction so it auto-released\n  inside the SELECT — defeating stampede prevention. Switched to\n  `pg_advisory_lock` + finally-block unlock around the full fetch +\n  persist critical section, so concurrent identical URLs serialise.\n- **`chat.message.hide_preview` enforces org isolation.** The handler\n  fetched messages by id alone with no `org_id` check, letting a\n  `chat:admin` in org A flip the hidden flag on org B's messages.\n  Switched to the canonical `loadMessageWithLocation` helper which\n  joins `chat_channels`/`chat_threads` and asserts org.\n- **Dangerous URL schemes refused at parse + schema.** `resolveUrl()`\n  in `og-parse.ts` now rejects non-http(s) schemes (was passing\n  `javascript:`, `data:`, `file:`, `gopher:` verbatim to the cache).\n  The unfurl Zod output schemas gain an http(s)-prefix refinement as\n  defense in depth so the contract never leaks a dangerous URL.\n- **External-fetch rate limit.** `chat.link.unfurl` (tagged\n  `external-fetch`) was falling through to the 600/min read bucket.\n  Tagged actions now get a tighter 60/min cap that legitimate\n  chat-paste behaviour easily clears but malicious fan-out hits.\n- **Recursion cap on TipTap doc walker.** `walkLinkMarks` was\n  recursive with no depth bound — a hostile client could post a\n  deeply-nested doc and overflow V8's call stack inside message-post.\n  Rewrote as iterative BFS with depth cap 32.\n- **Refresh cron uses `force: true` instead of expiry-bump hack.**\n  The cron's `UPDATE expires_at = now()-1s` window let concurrent\n  readers race past the lock; the action now accepts `force: true`\n  to bypass the cache cleanly. Cron also claims candidates with\n  `FOR UPDATE SKIP LOCKED` so a second worker can't double-fetch.\n\nPlus medium-severity polish: atomic SQL-level `failureCount`\nincrement, GC respects `NOT EXISTS chat_message_links` for blocked\nrows, SSRF audit logs gained `orgId` / `actorType` / `requestedUrl` /\n`urlHash` context, failed fetches now log structured warnings.\n\nThe cross-tenant leak in `chat.message.list_link_previews` (the\nmirror of the hide-preview bug) is fixed in a follow-up — its file\noverlaps with the in-flight ephemeral-messages branch.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.164Z","updatedAt":"2026-06-06T09:02:01.164Z"},{"id":"1fb735c7-023a-460d-91db-8d0f1981d8a5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-overdue-status-fix","type":"fixed","scope":"sales","summary":"Overdue invoices are now flagged automatically and can still be paid or credited — the status is no longer a manual-only dead-end.","body":"The `overdue` invoice status had two problems. It was **only reachable via the\nmanual \"Mark overdue\" button** — nothing flipped a past-due invoice to overdue\non its own, so the status (and the by-status breakdown that keys off it) was\nalmost never accurate. And once an invoice *was* marked overdue it became a\n**dead-end**: recording a payment or applying a credit note was rejected —\nexactly the actions you take when an overdue invoice finally settles.\n\nBoth are fixed:\n\n- The hourly sales maintenance job now sweeps every past-due, still-owing\n  invoice to `overdue`, using each org's own timezone, and re-runs harmlessly\n  (a paid-down invoice recomputes off overdue and won't re-flip).\n- Payments and credit notes (single apply **and** FIFO distribution) now accept\n  an overdue invoice; settling it recomputes the status to `partial`/`paid` as\n  usual.\n\nOverdue is now treated as **advisory** — the truth is always \"is this past due\nand still owing?\" (due date + balance), computed at read time. So if you grant\nan extension by pushing the due date out, the invoice immediately stops showing\nas overdue (and the stored status self-heals), instead of being stuck on the\nbadge forever. The sweep also got a concurrency hardening: it only flips an\ninvoice that is still past-due and issued/partial at write time, so a payment or\nextension landing mid-sweep can never be clobbered back to overdue.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.405Z","updatedAt":"2026-06-06T09:02:01.405Z"},{"id":"e79ef708-08f7-4ab1-a0bc-5d108ac90c33","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-overpaid-operator-alert","type":"added","scope":"sales","summary":"Operators are now alerted when a payment overpays an invoice (likely double-capture) so they can review and refund the surplus.","body":"When a recorded payment pushed an invoice's paid total *above* its amount — a\nlikely double-capture — the surplus was kept (money is never dropped) and the\n`sales.invoice.overpaid` event fired, but **nothing listened to it**, so no one\nwas told. Now the org's owners/admins get an in-app + email alert showing the\ninvoice total, amount paid, and the overpaid amount, with a link to review and\nrefund. Idempotent per (invoice, payment, recipient): a re-delivered event won't\nre-alert, but a genuinely new over-capture will.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.426Z","updatedAt":"2026-06-06T09:02:01.426Z"},{"id":"421884ed-3af1-475c-9e6e-d345d07825ca","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-bulk-contact-actions","type":"added","scope":"crm","summary":"Bulk-update (owner / status) or bulk-delete many contacts in one operation.","body":"New `crm.contact.bulk_update` (assign an owner and/or set status) and\n`crm.contact.bulk_delete` actions apply to up to 200 selected contacts at once,\nrespecting each user's update scope (own/team only touch their own rows). The\ncontacts list bulk bar and deals/leads bulk follow the same pattern next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.462Z","updatedAt":"2026-06-06T09:02:01.462Z"},{"id":"be4def3b-e1d3-4f2b-b348-dcdeecaec08e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-lead-capture-action","type":"added","scope":"crm","summary":"Capture a lead from a form or landing page, deduplicated on email.","body":"New `crm.lead.from_form` action maps a form / landing-page submission (name,\nemail, company, phone, job title, message + source/campaign attribution) into a\nCRM lead, deduplicating on email so a re-submit returns the existing lead instead\nof erroring. It's the lead-capture entry point for public forms (as a forms\n`submission.actionName` target), the AI assistant, and landing pages — the first\nslice of the CRM lead-forms work.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.475Z","updatedAt":"2026-06-06T09:02:01.475Z"},{"id":"91467674-3a88-4681-aa70-581c23209321","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"og-fetch-per-hop-timeout","type":"security","scope":"chat","summary":"og-fetch per-hop timeout now covers the streaming body read, not just the headers.","body":"The per-hop `hopTimer` in `@helios/og-fetch` was cleared in the\n`finally` block immediately after `undiciFetch()` resolved with\nresponse headers — but the streaming body-read loop runs AFTER that.\nA slow-body origin (drip-feeding bytes after sending headers quickly)\ncould consume the entire total budget on a single response, leaving\nremaining redirect hops effectively timeoutless beyond the total.\n\nRestructured to keep `hopTimer` + the pinned undici Agent alive until\nthe body read completes via a `cleanupHop()` closure called at every\nreturn path. The per-hop deadline now genuinely caps any individual\nhop's wall-clock — headers, body, and all.\n\n46 og-fetch tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.677Z","updatedAt":"2026-06-06T09:02:01.677Z"},{"id":"9e1dd4d2-d328-421e-8a06-6f032debd579","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-checkout-page-polish","type":"changed","scope":"payments","summary":"The checkout page got a professional polish — a loading skeleton, a card-form skeleton, a gentle card entrance, and a clearer \"Pay <amount>\" button.","body":"Polished the `/pay/c` checkout page to match the redesigned return page:\n\n- The bare \"Loading checkout…\" text is now a skeleton shaped like the real\n  checkout card, so there's no flash of blank space or layout jump.\n- While Stripe's secure card fields download and mount, the form area shows a\n  matching skeleton instead of plain \"Loading secure card form…\" text.\n- The checkout card fades + rises in gently on load (reduced-motion safe).\n- The pay button now reads **\"Pay $123.45\"** with a lock icon, and shows a\n  spinner with \"Processing your payment…\" while the charge is in flight.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.693Z","updatedAt":"2026-06-06T09:02:01.693Z"},{"id":"7a6d11e4-8f45-4dff-8948-050928aa32e1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-refund-email","type":"added","scope":"payments","summary":"Customers now get an email confirming their refund — refunds were processed silently with no notification.","body":"When a payment was refunded, the `payments.refund.succeeded` event fired (Sales\nreverses the invoice payment off it) but the **customer was never told** — no\nreceipt, no notification, their money just quietly came back days later.\n\nAdded a customer-facing refund confirmation email: on a successful refund the\ncustomer gets a reassuring note with the refunded amount, the original payment\nfor context, the reason where relevant, and a \"funds take a few business days to\nappear\" line, plus a link to their self-service payments portal. It carries the\noperator's branding (org → platform fallback, same as the receipt email) and is\nidempotent per refund.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.733Z","updatedAt":"2026-06-06T09:02:01.733Z"},{"id":"265f0159-dd00-4907-8e2c-b1e4fa635244","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-return-page-redesign","type":"changed","scope":"payments","summary":"The post-payment page is redesigned — animated step-by-step processing, a celebratory success with confetti, a clear failure state, and an auto-return to the invoice.","body":"The `/pay/return` page a customer lands on after paying was a plain text status.\nIt's now a polished, animated, plain-language experience for every outcome:\n\n- **Processing** — a Spaceship-style sequential progress view (securely\n  connecting → verifying details → confirming with your bank → preparing your\n  receipt) with a filling progress bar and a calm pulsing shield, so the wait\n  feels like steady forward motion instead of an indefinite spinner.\n- **Success** — a celebratory animated check with a confetti burst, a friendly\n  confirmation, and (when we know where they came from) an automatic redirect\n  back to their invoice after a few seconds, with a manual button too.\n- **Payment failed** — a brand-new, reassuring state (\"your card was declined,\n  you haven't been charged, try again\") instead of spinning forever. The\n  session-confirm action now reflects a terminally-failed payment so the page\n  can show this.\n- **Canceled / expired / still-confirming** — each gets its own animated icon\n  and clear next step.\n\nEverything carries the operator's branding (org → platform fallback) and respects\nreduced-motion (confetti + looping animations are skipped). No provider jargon or\nerror codes are shown to customers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.757Z","updatedAt":"2026-06-06T09:02:01.757Z"},{"id":"5e6ee184-ee4d-408a-bd66-3e02ccf82f3d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-lead-forms-wired","type":"added","scope":"crm","summary":"Form submissions can now create CRM leads automatically.","body":"A form whose submission target is the new `crm.lead.form_submitted` event now\ncreates a CRM lead the moment it's submitted (deduped on email). The forms\nmodule emits the form's configured submission event on submit; a CRM subscriber\ncaptures it with its own authority and runs `crm.lead.from_form`. This wires the\ndynamic-forms lead front-door to the pipeline — build a form, point its\nsubmission at `crm.lead.form_submitted`, and submissions land as leads.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.776Z","updatedAt":"2026-06-06T09:02:01.776Z"},{"id":"06eee163-0a38-4a46-8206-66749589de09","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-record-merge","type":"added","scope":"crm","summary":"Merge duplicate contacts or companies, moving all their related records onto the one you keep.","body":"You can now merge a duplicate contact or company into another. The duplicate's\ndeals, activities, deal links, and lead conversions (for contacts) — or\ncontacts, deals, activities, and child companies (for companies) — are\nre-pointed to the record you keep, the kept record's empty fields are filled in\nfrom the duplicate, and the duplicate is archived. New `crm.contact.merge` /\n`crm.company.merge` actions emit `crm.contact.merged` / `crm.company.merged` so\nother modules can re-point their own references.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.794Z","updatedAt":"2026-06-06T09:02:01.794Z"},{"id":"d3bf5496-2eff-46a0-974e-45dc82366900","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"sales-refund-dispute-fail-loud","type":"fixed","scope":"sales","summary":"A provider refund or lost chargeback that fails to update the invoice now retries instead of being silently dropped.","body":"The subscribers that reflect a provider-issued refund (`payments.refund.succeeded`)\nand reverse a lost chargeback (`payments.dispute.closed`) onto the invoice caught\nand logged any failure without re-raising it — so a transient error left the\ninvoice falsely showing the clawed-back money as still `paid`, with no retry. They\nnow throw on transient infrastructure failures so the outbox retries, while\ngenuine business rejections (e.g. a chargeback amount exceeding the remaining\nrefundable after a prior partial refund) are still logged for manual\nreconciliation rather than spun forever. Idempotency is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:01.947Z","updatedAt":"2026-06-06T09:02:01.947Z"},{"id":"55d10010-92fb-4ffa-aec5-3f69227cc244","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-approval-feedback-loop","type":"added","scope":"sales","summary":"The operator who requests invoice approval is now emailed the outcome — approved (issue it) or rejected (with the reason to fix).","body":"Invoice approval was a one-way street: an approver approved or rejected, but the\noperator who **requested** the approval had to keep refreshing the queue to find\nout — and a rejection (with its reason) reached them only if they happened to\nlook. Now both outcomes notify the requester in-app + by email:\n\n- **Approved** → \"you can issue it now\", with a link to the invoice.\n- **Rejected** → \"needs changes\", including the rejection reason, so they can\n  edit and re-request.\n\nIdempotent per decision, so re-requesting after a rejection and getting a fresh\ndecision notifies again, while a re-delivered event does not.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.048Z","updatedAt":"2026-06-06T09:02:02.048Z"},{"id":"d9633b3e-dba6-48c3-976a-1109be6c257b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"empty-states-premium","type":"changed","scope":"web","summary":"Replaced bare text empty states with the premium EmptyState component across portal and redirect lists.","body":"The client-portal document, invoice, quotation and team lists, and the website\nredirects list, now show the premium empty-state pattern (icon + title + description,\nwith a primary action where one applies) instead of a bare line of centered text.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.212Z","updatedAt":"2026-06-06T09:02:02.212Z"},{"id":"3d3652f4-04aa-41e5-bbcf-4b682b11452f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-changelog-leaf-tokens","type":"changed","scope":"web","summary":"Tokenized the public checkout, payment-methods portal, and changelog badge components.","body":"The hosted checkout page, the saved-payment-methods portal, the \"breaking\" badge,\nand the changelog subscribe card now use the platform's semantic design tokens\n(danger / success) instead of hard-coded Tailwind palette colors, so error and\nstatus states match the rest of the product across themes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.382Z","updatedAt":"2026-06-06T09:02:02.382Z"},{"id":"4c0805aa-0f2c-40db-bfa4-62b1997566d6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states","type":"changed","scope":"web","summary":"Loading states on the payroll, recruitment job detail and job-stats surfaces now use the shared Skeleton.","body":"Replaced hand-rolled pulsing placeholder blocks with the shared Skeleton primitive on\nthe payroll overview, the recruitment job-detail page, and the job-stats card, so their\nloading states match the rest of the app and the final layout (no layout shift).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.748Z","updatedAt":"2026-06-06T09:02:02.748Z"},{"id":"090f3f89-9e8c-40f1-a3a9-6bf8278f1763","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"adopt-button-badge-primitives","type":"changed","scope":"web","summary":"The changelog subscribe button and email-verification pills now use the shared Button/Badge primitives.","body":"The changelog \"Subscribe me\" button now uses the shared Button primitive (consistent\npress, focus ring and disabled styling), and the email-verification status pills now use\nthe shared Badge primitive instead of hand-rolled spans.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.067Z","updatedAt":"2026-06-06T09:02:02.067Z"},{"id":"45fb5745-fada-446b-8630-fadd33bf5183","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-tokens","type":"changed","scope":"web","summary":"Restyled the public client billing portal onto the platform's design tokens.","body":"The customer self-service billing portal now renders with the platform's neutral\nand semantic design tokens (invoice/credit status pills mapped to success / danger\n/ warning / info / accent) instead of a standalone zinc palette, so it follows the\noperator's branding and theme.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.093Z","updatedAt":"2026-06-06T09:02:02.093Z"},{"id":"3005357d-723c-4595-9edb-c0295de3bab3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"canonical-page-header","type":"changed","scope":"web","summary":"Unified module page headers onto one canonical scale so titles stop drifting in size across the app.","body":"Added a shared `PageHeader` primitive (eyebrow + title + description + actions) and\nbrought the Sales, HRM, HRM Performance and Projects/Tasks headers onto its single\ncanonical type scale (22px title, 10.5px eyebrow, 12.5px description). Headers\npreviously ranged 20–26px with mismatched tracking and colors, which read as\ninconsistent; they now match across modules.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.093Z","updatedAt":"2026-06-06T09:02:02.093Z"},{"id":"10643378-5e73-42b6-a032-ce0515ce5400","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"public-auth-onboarding-tokens","type":"changed","scope":"web","summary":"Restyled the signup, onboarding and public offer pages onto the shared design tokens.","body":"The signup error banners, the new-hire onboarding welcome page, and the public\noffer accept/decline page now use the platform's semantic and brand design tokens\ninstead of hard-coded Tailwind palette colors, so they follow the operator's\nbranding and stay legible across themes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.393Z","updatedAt":"2026-06-06T09:02:02.393Z"},{"id":"dbcfde63-3558-4531-b24a-980d6a486d7b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"routine-toast-and-a11y","type":"changed","scope":"web","summary":"Dropped success toasts on routine edits and labelled the project artifacts search for screen readers.","body":"Routine create/update flows (user profile, SaaS announcements, feature-toggle overrides)\nno longer fire a success toast — matching the convention that routine edits confirm\nsilently. The project data search input now has an accessible name for screen readers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.602Z","updatedAt":"2026-06-06T09:02:02.602Z"},{"id":"95a151ab-db84-45fb-8fa5-e2351295a963","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"flat-canvas","type":"changed","scope":"ui","summary":"Flattened the app canvas (removed the dotted-grid texture) for a cleaner, more modern surface.","body":"The application background is now a plain flat fill instead of a faint dotted-grid\n\"paper\" texture. Structure comes from hairline borders and surface elevation — the\nflat-canvas direction shared by Linear, Vercel and Attio. Reversible: restore the\n`body` radial-gradient in `packages/ui/src/globals.css` to bring the texture back.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.258Z","updatedAt":"2026-06-06T09:02:02.258Z"},{"id":"141ec988-6947-4265-89fe-7ef692b9dfd5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"forms-system-seed-root-gate","type":"security","scope":"forms","summary":"forms.system.seed is now root-only; previously any authenticated user could trigger cross-tenant system-form writes.","body":"`forms.system.seed` upserts shared `org_id IS NULL`, `is_system=true` form\ndefinitions that every tenant reads. Its policy was `() => allow()`, so any\nauthenticated tenant user (or AI token) could invoke it through the action edge\nand churn the version history of the platform's system forms across all orgs.\nImpact was bounded — it re-applies the shipped seed bodies idempotently — but it\nwas an unauthorized cross-tenant write. It now requires `platform:root`. Adds\ndeny/allow regression tests.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.317Z","updatedAt":"2026-06-06T09:02:02.317Z"},{"id":"1496f73d-7a5b-4b8f-b1a5-9f14d26e24c9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-money-path-races","type":"fixed","scope":"payroll","summary":"Disbursing or posting a payroll run is now transactional + race-safe, and a paid disbursement can no longer be silently re-marked.","body":"The payroll run money path had the same read-then-write gaps that were just\nclosed in expenses/recruitment, but over money:\n\n- **`payroll.run.disburse`** read the run as `finalized`, then inserted a batch +\n  one disbursement row per employee, then flipped status with an id-only UPDATE.\n  Two concurrent calls both passed the read and each inserted a full set of\n  disbursements — paying every employee twice. It now runs inside a transaction\n  that claims the `finalized → disbursed` transition with a compare-and-set up\n  front; the loser matches 0 rows and the whole transaction rolls back\n  (`conflict`).\n- **`payroll.run.post`** inserted journal entries and additively bumped each\n  employee's YTD totals before the status flip, non-transactionally. A concurrent\n  double-post (or a mid-loop failure) double-counted the GL + YTD. It now claims\n  `finalized|disbursed → posted` inside a transaction; everything rolls back on a\n  lost race or any error.\n- **`payroll.disbursement.mark`** updated status keyed on id only, with no\n  terminal-state guard — a `paid`/`voided` row could be re-marked (re-emitting the\n  money event) or silently flipped. The UPDATE now excludes terminal states;\n  re-marking the same terminal status is an idempotent no-op, and any other change\n  to a terminal row returns `conflict`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.382Z","updatedAt":"2026-06-06T09:02:02.382Z"},{"id":"b2ac57f7-09f7-433d-9ca7-c4df41522267","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-select-to-primitive","type":"changed","scope":"web","summary":"Replaced remaining raw native dropdowns with the polished Select component.","body":"The AI model picker, the task filter dropdowns (status / priority / project / assignee /\nteam / cycle / milestone / section), and the audit-retention dropdown now use the shared\nSelect primitive instead of raw native `<select>` elements, for consistent styling and\nfocus treatment across the app.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.385Z","updatedAt":"2026-06-06T09:02:02.385Z"},{"id":"a408a1be-1c32-4aed-87db-dd235cc02146","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ui-foundation-motion-contrast","type":"fixed","scope":"ui","summary":"Fixed the checkbox and status-tooltip contrast in dark mode and added the missing spring-soft motion token.","body":"The checkbox checkmark now uses the on-accent foreground token so it stays legible\nwhen the brand accent is a light color, and the info/success/warning/danger tooltip\ntext now adapts with the tooltip chip instead of being hard-coded white (which was\nunreadable on the lighter chip in dark mode). Added the `--ease-spring-soft` motion\ntoken several surfaces already referenced.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.742Z","updatedAt":"2026-06-06T09:02:02.742Z"},{"id":"bc735fdb-b6a1-472c-aaf2-69979a3e1af2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"recruitment-application-transition-cas","type":"fixed","scope":"recruitment","summary":"Advancing or rejecting an application is now race-safe — concurrent reviewers can no longer double-transition it or double-fire the pipeline engine.","body":"`recruitment.application.advance` and `recruitment.application.reject` read the\napplication's status, validated the transition, then wrote with an id-only\nUPDATE. Two reviewers acting at once (two advances, or an advance racing a\nreject) could both pass validation and both write — producing duplicate\ntimeline events and double pipeline-engine fan-out. Each UPDATE now\ncompare-and-sets on the status that was read (+ `org_id`); a 0-row result\nreturns `conflict` and skips the event insert + emit, so only the winning\ntransition takes effect.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T09:02:02.478Z","updatedAt":"2026-06-06T09:02:02.478Z"},{"id":"6ff9c2ff-5f54-45b9-b6c7-75561e5a130e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ai-tool-schema-transform-resilience","type":"fixed","scope":"ai","summary":"Mentioning the AI assistant in chat works again — one action's transformed input schema no longer breaks the whole tool list.","body":"The AI tool list is built by converting every action's Zod input schema to JSON\nSchema. Zod 4's converter throws on a schema containing a `.transform()`\n(\"Transforms cannot be represented in JSON Schema\"), and the builder converted\nthe whole registry in one pass — so a single action with a transformed input\ntook down the entire tool list. That silently broke the in-chat AI mention: every\nmessage that @-mentioned the assistant failed and was parked, for weeks.\n\nThe converter now (1) represents the input shape the AI actually sends\n(`io: 'input'`), (2) emits a permissive schema instead of throwing on\nunrepresentable constructs (`unrepresentable: 'any'`), and (3) falls back to a\nloose object schema per-action if conversion still fails — so one exotic schema\ncan never again break tool generation for every other action.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:47.093Z","updatedAt":"2026-06-06T14:19:47.093Z"},{"id":"ee2b0ba4-d287-4fa8-a9a4-9ed8d325e22d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-lead-forms-admin","type":"added","scope":"crm","summary":"A \"Lead forms\" panel on the CRM hub to create + share public lead-capture forms.","body":"The CRM hub gains a **Lead forms** panel: create a public lead-capture form\n(pre-wired so submissions drop straight into your leads via\n`crm.lead.form_submitted`), copy its shareable `/apply/<org>/<form>` link, open\nit in the form builder to customize, and see all your CRM lead forms with\npublished/draft status. Completes the lead-forms loop (capture action + event\ndispatch + admin surface).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:47.686Z","updatedAt":"2026-06-06T14:19:47.686Z"},{"id":"0d54d758-3030-4b8c-bdd9-12948d9b9e8e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-record-field-groups","type":"added","scope":"crm","summary":"Contact records group their fields into collapsible sections.","body":"Record sections can now collapse (a new option on the shared record-section\nprimitive), and the contact record organizes its fields into **General /\nLifecycle / System** groups (System collapsed by default) — the Twenty-style\ngrouped record layout. The other CRM records adopt the same grouping next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:47.699Z","updatedAt":"2026-06-06T14:19:47.699Z"},{"id":"596608f0-67fc-4cd5-87fa-cceffce3f867","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-failed-decline-hint","type":"changed","scope":"payments","summary":"The \"payment didn't go through\" email now gives a friendly, specific next step based on why the card was declined.","body":"The dunning email previously showed only the provider's raw failure message,\nwhich can be terse (\"Your card was declined.\"). It now leads with a friendly,\nplain-language next step derived from the decline code — e.g. insufficient funds\n→ \"it's worth trying another card\", expired card → \"please use a different\ncard\", CVC mismatch → \"please re-enter your card details\", authentication\nrequired → \"your bank needs to verify this — try again and complete the step\".\nThe raw provider detail is kept below as secondary context. No new template or\nemail is added — it's a refinement of the existing `payments.payment_failed`\nflow, so declined and other failures share one flexible email.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:47.967Z","updatedAt":"2026-06-06T14:19:47.967Z"},{"id":"b9af009c-d542-401f-b8a9-cc231d87b499","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-checkout-static-states-polish","type":"changed","scope":"payments","summary":"The checkout page's not-found / expired / canceled / already-paid screens now get animated icon scenes and friendlier copy, matching the return page.","body":"Finished polishing `/pay/c`: the terminal screens (checkout-not-found, link\nexpired, canceled, already-paid) were plain headings — they now get the same\nspringing animated icon badges, balanced titles, and plain-language copy as the\nredesigned return page. The provider hand-off (\"Continue to Stripe →\") is now a\nfull-width branded button with a lock icon and a clearer \"you'll come right back\nhere\" explanation, provider names are presented nicely (stripe → Stripe), and\nthe pre-redirect wait shows a spinner instead of bare text.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:47.967Z","updatedAt":"2026-06-06T14:19:47.967Z"},{"id":"aa3b73d2-ca88-45df-85c4-35811ff57b3b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-input-to-primitive-a","type":"changed","scope":"web","summary":"Replaced raw text/number/email inputs with the polished Input component across 17 form-heavy screens.","body":"Converted 85 raw native text-like `<input>` fields (text / number / email / search /\nurl / password) to the shared Input primitive across the sales, projects, HRM,\nrecruitment, careers-apply, clients and SaaS-admin forms, so every field has the same\nhairline border, focus ring and sizing. Checkbox / date / file / color inputs are\nhandled in follow-up passes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:48.266Z","updatedAt":"2026-06-06T14:19:48.266Z"},{"id":"075d1266-e80c-4ee0-9624-5cb87234f6ed","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payment-portal-polish","type":"changed","scope":"payments","summary":"The customer self-service payments portal got a polish — brand accent bar, icon badges on saved methods and receipts, a loading skeleton, and a friendlier not-found screen.","body":"Rounded out the `/pay/portal` self-service page so all three pay pages\n(checkout, return, portal) feel consistent: a thin brand-colour accent bar at\nthe top, a loading skeleton instead of bare \"Loading…\", an animated icon for the\nexpired/revoked \"portal not found\" screen, and a card/bank/receipt icon badge on\neach saved-method and receipt row. The content fades in gently (reduced-motion\nsafe). Behaviour is unchanged — presentation only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:19:48.267Z","updatedAt":"2026-06-06T14:19:48.267Z"},{"id":"213db45e-6af4-496c-8658-5210aa15758c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-field-groups-deal-lead","type":"added","scope":"crm","summary":"Deal and lead records also group their fields into collapsible sections.","body":"Following the contact record, the **deal** record (Deal / System) and **lead**\nrecord (General / Lead / System) now organize their fields into collapsible\ngroups, with System collapsed by default — a consistent Twenty-style grouped\nrecord layout across all CRM records.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T14:59:36.501Z","updatedAt":"2026-06-06T14:59:36.501Z"},{"id":"ce22ae82-b4fe-4c1a-a307-f1ec81031473","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-ai-assistant-whitelabel-identity","type":"fixed","scope":"chat","summary":"The in-chat AI assistant now introduces itself with the operator's app name instead of the \"Helios\" codename.","body":"The system prompt for the in-chat AI assistant hard-coded \"You are Helios, the\nembedded AI assistant\" (and called tools \"a real Helios action\"), so on a\nwhite-labelled deployment the assistant would refer to itself by the codename\nrather than the operator's brand. The prompt now resolves the configured\n`app_name` from platform settings and uses a brand-neutral identity when none is\nset, matching the no-static-branding contract.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:37.768Z","updatedAt":"2026-06-06T15:28:37.768Z"},{"id":"023eb09d-cfe3-4d21-b6bd-feb5bf0602dd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-favorite-toggle-ui","type":"added","scope":"crm","summary":"A ★ button on contact, deal, and lead records to favorite them.","body":"Contact, deal, and lead record headers now have a ★ toggle to favorite the\nrecord — a personal, per-user starred list that updates instantly across the\npage. Completes record favorites (data + actions + UI).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:38.577Z","updatedAt":"2026-06-06T15:28:38.577Z"},{"id":"4feacbe3-b5c6-4170-94de-66d1fc49c822","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-record-favorites","type":"added","scope":"crm","summary":"Star your most-used contacts, companies, deals, and leads.","body":"You can now favorite (★) any CRM record — contacts, companies, deals, leads — a\npersonal, per-user list. New `crm.favorite.toggle` / `crm.favorite.list` actions\nback it (with a new `crm_favorites` table); the ★ on record headers + a\nfavorites view land next.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:38.620Z","updatedAt":"2026-06-06T15:28:38.620Z"},{"id":"056cb09e-a0bc-4ed7-976a-92fd8f46aff0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"public-quotation-page-parity","type":"changed","scope":"sales","summary":"The public quotation page now matches the invoice page — a brand accent bar and a clear status/validity banner (accepted, declined, expired, \"valid until\").","body":"Brought the recipient-facing `/q/<id>` proposal page up to the polished public\ninvoice page's bar:\n\n- A thin **brand-colour accent bar** across the top (on the loaded page and the\n  loading skeleton), so a proposal reads as the sender's, not a generic page.\n- A tone-tinted **status / validity banner** in the hero replacing the old plain\n  \"final state\" line: green \"Accepted — thank you\", red \"Declined\", neutral\n  \"Withdrawn\", amber \"This proposal expired on …\", or a calm \"Valid until …\n  (N days left / today)\" for live proposals — mirroring the invoice page's\n  paid / overdue / void banners.\n\nPresentation only; the accept / decline / download flow is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:38.839Z","updatedAt":"2026-06-06T15:28:38.839Z"},{"id":"3a5ed55e-579b-4ed5-87e5-ef8df8787e7d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-input-to-primitive-b","type":"changed","scope":"web","summary":"Converted more raw text inputs to the polished Input component across form-builder and widget components.","body":"Converted the remaining raw text-like `<input>` fields in the form-builder field/validation\neditors, job-create sheet, magic-link sign-in, engagement-link block, AI-plan panel,\ncustom-fields admin panel, milestone editor and paste-to-tasks panel to the shared Input\nprimitive. Checkbox / date inputs in these files are handled in follow-up waves.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:38.840Z","updatedAt":"2026-06-06T15:28:38.840Z"},{"id":"d6f5bc5a-aa4f-41d1-8ce3-b931fea69e42","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-detach-clears-default","type":"fixed","scope":"payments","summary":"Detaching a customer's default payment method now clears the saved default, so a later charge can't try an invalidated token.","body":"`payments.method.detach` and `payments.portal.detach_method` flagged the method\nrow detached (and revoked its token at the provider) but left\n`payment_customers.default_method_id` pointing at it. A subsequent charge that\nresolved the customer's default would pick a detached, token-revoked method and\nfail. Both actions now clear `default_method_id` when it referenced the detached\nmethod (the customer/portal re-prompts for a new default).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:39.107Z","updatedAt":"2026-06-06T15:28:39.107Z"},{"id":"60926fb7-80d9-4fad-95c0-a01dcbdc0ca1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-input-to-primitive-c","type":"changed","scope":"web","summary":"Converted remaining raw text inputs to the Input component in task inline-editors and a few forms.","body":"The inline add-task row, inline status/title editors, save-view modal, client-portal\nquotation search and careers search now use the shared Input primitive instead of raw\nnative `<input>`. Most other scanned forms were already on the primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T15:28:39.112Z","updatedAt":"2026-06-06T15:28:39.112Z"},{"id":"1b2d08b2-8f87-4ae1-83c6-359d335baf6b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contact-send-email-ui","type":"added","scope":"crm","summary":"A Send-email button + compose dialog on the contact record.","body":"The contact record header now has a ✉ Send-email action that opens a compose\ndialog (subject + message), sends through the unified email module, and logs the\nemail on the contact's timeline. Completes record email for contacts.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.385Z","updatedAt":"2026-06-06T16:06:59.385Z"},{"id":"a03ba313-6ed3-4c8e-bd80-23928982f9a4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-record-prev-next-nav","type":"added","scope":"crm","summary":"Step through records with ▲▼ buttons and J/K keys.","body":"Contact, deal, and lead record headers now have previous/next (▲▼) controls —\nplus **J / K** keyboard shortcuts — to step through the records in your current\nlist without returning to it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.401Z","updatedAt":"2026-06-06T16:06:59.401Z"},{"id":"96773ce9-2e71-4f4f-9583-13de403e6026","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-contact-send-email","type":"added","scope":"crm","summary":"Email a contact from the CRM and log it on their timeline.","body":"New `crm.contact.send_email` action sends an operator-composed email to a contact\nthrough the unified email module and logs it as an `email` activity on the\ncontact's timeline. Scope-aware (own/team may only email their own contacts).\nThe compose dialog on the record lands next (F4b).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.401Z","updatedAt":"2026-06-06T16:06:59.401Z"},{"id":"f45d9d1c-20df-42ca-af5d-473a82d3419f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-list-aggregation-footer","type":"added","scope":"crm","summary":"The contacts list shows per-column totals in a footer row.","body":"List grids can now show a per-column aggregation footer (the shared data grid\ngained an opt-in `aggregations`). The contacts list uses it for live rollups —\ncontact count, unique companies, and customer count — the Twenty-style footer.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.409Z","updatedAt":"2026-06-06T16:06:59.409Z"},{"id":"25dc801e-48c7-4de0-8283-eed79b0df838","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-input-to-primitive-d","type":"changed","scope":"web","summary":"Converted the remaining raw text inputs across help, sales, recruitment, projects and auth pages to the Input component.","body":"The help/contact + article follow-up forms, quotation/credit-note/invoice share fields,\nrecruitment recruit + review inputs, project category/label editors, roster filters, the\npublic offer field, and the LDAP / email-OTP / step-up auth inputs now use the shared\nInput primitive instead of raw native `<input>`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.670Z","updatedAt":"2026-06-06T16:06:59.670Z"},{"id":"3688168c-d9cd-4f5a-8382-bb9fa13aebee","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-balance-reconcile","type":"added","scope":"sales","summary":"New read-only audit (sales.invoice.reconcile) detects any invoice whose stored amount-paid cache has drifted from its payments, refunds, and applied credits.","body":"`sales_invoices.amount_paid_cents` is a denormalized running total maintained by\nfour write paths (record / delete payment, apply / reverse credit) plus refunds.\nA bug in any one — or a manual DB edit — could silently desync it from the\nsource rows, quietly corrupting every balance, aging, and dashboard figure that\ntrusts it, with no way to notice.\n\nAdded `sales.invoice.reconcile`: a read-only audit that recomputes the true paid\ntotal from the source tables —\n`SUM(payments not-deleted) − SUM(refunds not-failed) + SUM(credit-note applications)`\n— and returns every invoice whose stored cache diverges, with the exact delta.\nScans the whole org or a single invoice; never writes (it's a detector). Also\ncorrected the schema's invariant comment, which previously omitted the\ncredit-applications term and was wrong.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.622Z","updatedAt":"2026-06-06T16:06:59.622Z"},{"id":"add70055-da33-4d69-8954-7acf5177ffb0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-checkbox-to-primitive-c","type":"changed","scope":"web","summary":"Converted native checkboxes in field-builders, recruitment, website and project editors to the Checkbox component.","body":"Converted native `<input type=\"checkbox\">` toggles to the shared Checkbox primitive across\nthe CRM/forms field builders, generic provider form, job-questions editor, recruitment flow\nsettings, website blog/global editors, project custom-fields/recurring-task/view editors,\nclient detail, and HRM clock-policy/directory surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.651Z","updatedAt":"2026-06-06T16:06:59.651Z"},{"id":"60cdbd34-0e3f-4fc6-984c-22d0554d9d51","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"invoice-pdf-logo-top-left","type":"fixed","scope":"sales","summary":"Invoice and quotation PDF logos now sit flush at the top-left of the letterhead instead of floating low with empty space above them.","body":"The invoice / quotation PDF letterhead sized the org logo inside a fixed\n96×96 square box with `object-fit: contain`. A typical wordmark logo (wide\nand short) fit that box by its width and then centred vertically inside the\ntall square — leaving ~35px of empty space above the logo, so it appeared to\nfloat well below the top of the page and out of line with the document number\non the right.\n\nThe logo is now sized by height (with a max-width cap for very wide marks),\nmatching the shared document letterhead convention: it renders at its natural\naspect ratio, flush to the top-left, whether it's a square symbol or a long\nwordmark.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.653Z","updatedAt":"2026-06-06T16:06:59.653Z"},{"id":"59b4d5c6-ac39-48cc-a72c-d6ae0f5f5891","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-checkbox-to-primitive-d","type":"changed","scope":"web","summary":"Converted more native checkboxes to the Checkbox component across payroll, SaaS and public offer surfaces.","body":"Converted native `<input type=\"checkbox\">` controls to the shared Checkbox primitive on the\npayroll group member-picker and self-service, the public offer page, the SaaS\noperator-alerts filter, and the website audit/find admin lists.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.667Z","updatedAt":"2026-06-06T16:06:59.667Z"},{"id":"22bd78e9-4fbf-425c-b4e7-e4acb4ca34b6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-input-to-primitive-e","type":"changed","scope":"web","summary":"Finished migrating raw text inputs to the Input component across projects, recruitment, SaaS and settings forms.","body":"Completed the text-input migration: the search dock, careers application field adapter,\nproject cycle/milestone/template forms, recruitment applications search, SaaS auth-provider\nand organization forms, and settings forms now use the shared Input primitive. Deliberately\nbespoke borderless fields (inline title editors, filter pills, the signup invite row) were\nleft as-is since the Input primitive has no ghost variant yet.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.688Z","updatedAt":"2026-06-06T16:06:59.688Z"},{"id":"2734e18c-dcb9-487b-8ab3-908636e60c00","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-webhook-provider-scope","type":"fixed","scope":"payments","summary":"Dispute and saved-method webhooks are now scoped to the owning provider, preventing a cross-tenant mismatch.","body":"Two webhook handlers matched their target row by the provider's id alone\n(`provider_dispute_id`, `provider_method_id`), but those ids are only unique\n*per provider* — so a webhook from one provider carrying an id that collided\nwith another provider's row could flip the wrong org's dispute (and reverse the\nwrong invoice) or detach another org's saved card. Both now match on\n`(provider_id, provider_*_id)`, matching the already-correct refund/charge\nhandlers. The dispute handler additionally refuses to un-resolve a dispute that\nalready closed won/lost when a late, out-of-order non-terminal update arrives.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.875Z","updatedAt":"2026-06-06T16:06:59.875Z"},{"id":"9df4b2f7-80b9-4eb9-950f-45381d3b9df9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-checkbox-to-primitive-b","type":"changed","scope":"web","summary":"Converted more native checkboxes to the Checkbox component across sales, SaaS, HRM and CRM lists.","body":"Converted native `<input type=\"checkbox\">` controls (including select-all/indeterminate\nheader checkboxes) to the shared Checkbox primitive across the sales recurring/quotation/\ninvoice/credit-note lists, SaaS website/webhooks/email/org/audit admin, HRM roster/leave/\ndirectory, expenses, CRM contacts and the AI-plan panel.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.894Z","updatedAt":"2026-06-06T16:06:59.894Z"},{"id":"cb798aff-b0c5-4e46-b5c7-9b657fbdbfb1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-checkbox-to-primitive-a","type":"changed","scope":"web","summary":"Replaced raw native checkboxes with the polished Checkbox component across 18 high-density forms.","body":"Converted 62 raw native `<input type=\"checkbox\">` controls to the shared Checkbox primitive\nacross the translations, time-tracking, HRM settings, website section editor, sales\ninvoice/settings, clients, users, payroll, SaaS admin and support settings surfaces —\nincluding correct indeterminate (select-all) handling. Consistent box styling, focus ring\nand tactile press everywhere.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.894Z","updatedAt":"2026-06-06T16:06:59.894Z"},{"id":"0e519157-08d3-46d1-b236-ab25682fc6bb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"input-ghost-variant","type":"added","scope":"ui","summary":"Added a borderless ghost variant to the Input primitive so inline/embedded fields can use the shared component.","body":"Added `variant=\"ghost\"` to the Input primitive — a borderless, transparent field with no focus\nframe, for inline fields embedded in a composite that supplies its own chrome (filter-chip\npills, inline editors, single-field-with-button rows). It opts out of the global field focus\nring so the host's own affordance shows through. Adopted it on the task filter-chip search so\nthat field now uses the shared Input instead of a raw `<input>`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.542Z","updatedAt":"2026-06-06T17:00:16.542Z"},{"id":"127457cb-7059-4150-bdad-f81fce8e09f7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-builder","type":"added","scope":"crm","summary":"Build and run CRM automations from the CRM hub — compose steps, map fields, run on demand.","body":"Added an Automations builder to the CRM hub (for admins with\n`crm:workflow:manage`). Compose a workflow from ordered \"create a record\"\nsteps — pick the record type (lead / contact / company / deal) and map each\nfield to a literal value or a `{{ variable }}` template. Run any automation on\ndemand by supplying its input variables; the per-step result (created record or\nerror) shows inline. Backed by the new `crm.workflow.{create,list,run}`\nactions; opens as a slide-over, no navigation away from the hub.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.979Z","updatedAt":"2026-06-06T17:00:16.979Z"},{"id":"c01a1d11-0564-4b11-9f82-e3a789274296","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-radio-to-radiogroup","type":"changed","scope":"web","summary":"Native radio groups now use the polished RadioGroup/Radio primitives.","body":"Converted native `<input type=\"radio\">` groups to the shared RadioGroup/Radio primitives —\nthe question copy-mode, CRM contact portal-scope, project visibility, domain verification-method\n(setup + edit), and translation provider pickers — for consistent styling, keyboard roving\nfocus and accessibility. The table-row default-locale radios were left native (they span\nseparate table cells and can't be wrapped in a single group without breaking the table).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.072Z","updatedAt":"2026-06-06T17:00:17.072Z"},{"id":"c8052f4b-7291-4940-9965-c42a03302028","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-range-to-slider","type":"changed","scope":"web","summary":"The watermark opacity/softness range inputs now use the polished Slider component.","body":"Converted the legal/watermark opacity and softness range inputs to the shared Slider\nprimitive (themed track + thumb, keyboard accessible). The performance goal-progress slider\nkeeps its native commit-on-release behavior for now (it intentionally mutates only on drag\nrelease inside a list row).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.088Z","updatedAt":"2026-06-06T17:00:17.088Z"},{"id":"1e6b4fb1-bd2e-44b8-813e-7eb0cc1515a3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"refund-idempotency-and-system-actor","type":"fixed","scope":"payments","summary":"Concurrent duplicate refund requests now return the existing refund instead of erroring, and system-initiated refunds no longer hit a foreign-key error.","body":"`payments.refund.create` had an idempotency pre-check but no catch on the insert,\nso two concurrent requests with the same idempotency key could both pass the\ncheck and the loser would fail with a 500 instead of the idempotent \"already\nrecorded\" result — contradicting the stated guarantee (the matching pattern from\n`payments.intent.create` was missing). It now catches the unique-violation on\n`(org_id, idempotency_key)` and returns the winning refund row. Separately, the\nrefund row's `created_by` is now written as `null` for a system-context caller\n(rather than the all-zeros sentinel that has no `users` row), so a future\nautomated refund flow won't hit a foreign-key violation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:59.911Z","updatedAt":"2026-06-06T16:06:59.911Z"},{"id":"f7599112-3ca8-412c-9f51-a036a1588a10","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-ical-subscription-feed","type":"added","scope":"calendar","summary":"Subscribe to your Helios calendar from Google, Apple, or Outlook with a personal, auto-refreshing iCalendar (.ics) feed URL.","body":"The calendar page has a new **Subscribe** button that gives you a personal\niCalendar feed URL. Paste it into Google Calendar, Apple Calendar, or Outlook\n(or click \"Add to calendar app\") and your tasks, interviews, leave, invoices,\ndeal close dates, and everything else on your Helios calendar show up there,\nread-only and auto-refreshing.\n\nThe feed is a signed, sessionless link served at `/api/calendar/feed.ics`. The\nsignature is an HMAC keyed by a server secret, so only you can obtain your own\nURL (through the app) and nobody can forge one for someone else. The feed\nrebuilds your exact permissions on each fetch, so it shows precisely what you'd\nsee in the calendar — nothing you can't already access. It covers a rolling\n~3-month window and refreshes about hourly.\n\nKeep the link private: anyone who has it can view your calendar. Removing a\nuser's access to the workspace disables their feed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.260Z","updatedAt":"2026-06-06T17:00:16.260Z"},{"id":"41293a49-68d5-43b6-bdfe-bdaae8e25265","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-deal-close-quote-expiry-sources","type":"added","scope":"calendar","summary":"The unified calendar now shows CRM deal close dates and sent-quotation expiry dates alongside tasks, interviews, leave, and the rest.","body":"Two new derived sources were added to `calendar.event.list_for_actor`:\n\n- **`deal_close`** — open CRM deals (won/lost deals are excluded) appear on\n  their target close date. Under the `me` scope they are narrowed to the\n  calling user's own deals.\n- **`quotation_expiry`** — quotations in the `sent` state appear on their\n  expiry date so they don't lapse unnoticed.\n\nBoth are all-day events that turn `danger`-toned once the date has passed.\nLike every calendar source they are read-only and inherit the visibility of\ntheir owning module, so a user who can't read deals simply sees zero\n`deal_close` entries.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.275Z","updatedAt":"2026-06-06T17:00:16.275Z"},{"id":"7bcf3614-c698-49c6-b3b1-60aad5e27366","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-month-cell-fix-and-accessible-reschedule","type":"fixed","scope":"web","summary":"Fixed calendar month cells so clicking an event opens it cleanly, added a keyboard-accessible task reschedule, and stopped month/week navigation from flashing a skeleton.","body":"The calendar month grid wrapped each event in the day-cell button, so\nclicking an event both opened its detail panel and jumped to the day view at\nthe same time. Day cells are now plain containers with the date number and an\nexplicit \"+N more\" control as the day-pick affordances, and each event is its\nown button — clicking an event just opens it.\n\nTwo further improvements ride along:\n\n- The event detail panel now has a keyboard-reachable **Reschedule** date\n  picker for tasks (previously rescheduling was only possible via mouse drag).\n- Stepping through months/weeks keeps the current events on screen while the\n  next range loads instead of flashing a loading skeleton, and the header\n  shows a live count of the events in view.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.278Z","updatedAt":"2026-06-06T17:00:16.278Z"},{"id":"c1f69c40-f660-4b24-914d-d92c201a37ae","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"ghost-input-adoption","type":"changed","scope":"web","summary":"Inline borderless task fields now use the shared Input (ghost) primitive instead of raw inputs.","body":"The task-detail title + sub-task draft fields and the quick-add task title now use the shared\nInput `variant=\"ghost\"` primitive, preserving their borderless look and animated focus underline\nwhile no longer relying on a raw native `<input>`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.534Z","updatedAt":"2026-06-06T17:00:16.534Z"},{"id":"6e3552cb-f0a7-4bdc-830b-083f06779ef9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"datefield-popover","type":"added","scope":"web","summary":"Added a DateField wrapper (polished calendar popover) and adopted it on milestone, project and leave date pickers.","body":"Added a reusable `DateField` component that bridges the app's `yyyy-mm-dd` string convention to\nthe polished `DatePicker` calendar popover, with timezone-correct (local) parsing/formatting\ncentralized in one place. Adopted it on the milestone due-date, new-project target-date, and\nleave start/end date fields — those now open the custom keyboard-accessible calendar instead of\nthe browser's native date popup. Bulk data-entry date fields (invoice/payroll line items) keep\nthe type-friendly `<Input type=\"date\">`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.528Z","updatedAt":"2026-06-06T17:00:16.528Z"},{"id":"38eba985-bb52-429e-9611-9d24f0d0a705","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-checkbox-to-primitive-e","type":"changed","scope":"web","summary":"Converted native checkboxes across login, payroll, projects, recruitment and SaaS website admin to the Checkbox component.","body":"Converted native `<input type=\"checkbox\">` controls to the shared Checkbox primitive on the\nlogin trust-device toggle, payroll run selection, project data + template defaults,\nrecruitment review/bulk/library/settings, and the SaaS website inventory/new/presets/\ntemplates/webhooks admin lists.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.801Z","updatedAt":"2026-06-06T17:00:16.801Z"},{"id":"31588485-2e06-44b8-aee6-b4d46cde794c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-date-to-input","type":"changed","scope":"web","summary":"Raw native date/datetime/time inputs now use the polished Input component.","body":"Converted raw native `<input type=\"date\">` / `datetime-local` / `time` fields to the shared\nInput primitive (consistent hairline frame + focus ring, matching the date fields already on\nthe primitive) across the project/task/milestone/recurring editors, invoice payment, projects\nsettings/cycles, sales recurring, audit filters, maintenance scheduler and notification\nquiet-hours forms.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.817Z","updatedAt":"2026-06-06T17:00:16.817Z"},{"id":"0c6e9ec8-53c3-49fb-a0b1-2c5aa085c78e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"native-checkbox-to-primitive-f","type":"changed","scope":"web","summary":"Finished the checkbox migration — settings, email, IAM and sales toggles now use the Checkbox component.","body":"Completed the checkbox migration: tax-rate compound, audit grouping, email provider/routing/\ntemplate, comp-review, equity, domain setup, payments, roles, support branding/menus, teams\nand user-detail toggles now use the shared Checkbox primitive. The only native checkbox left\nis the payroll-codes FlagToggle (a button-wrapped indicator where Checkbox-in-button would be\ninvalid HTML).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.817Z","updatedAt":"2026-06-06T17:00:16.817Z"},{"id":"4e394b6c-4cfb-47f0-92b2-2567c8f47c77","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-trigger-ui","type":"added","scope":"crm","summary":"Pick an automation's trigger and activate or pause it from the CRM Automations builder.","body":"The CRM Automations builder now exposes the full trigger model. When composing\nan automation you can choose how it runs — \"Run manually\" or \"When a record\nevent happens\" (a lead is created, a deal is won, a deal changes stage, …). Each\nautomation in the list gets an Activate / Pause toggle; only active automations\nfire on their event. Surfaces the `crm.workflow.set_status` action in the UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:16.977Z","updatedAt":"2026-06-06T17:00:16.977Z"},{"id":"c2482263-778a-45e5-87d8-2fb823591fea","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payments-receipt-platform-charge","type":"fixed","scope":"payments","summary":"Payment receipts are no longer dropped for signup/marketing plan purchases.","body":"The payment-receipt email subscriber inner-joined the `organizations` table on\nthe charge's org. Platform-tenant charges (signup and marketing plan purchases,\nwhich run under the platform sentinel org) may have no `organizations` row, so\nthe inner join silently dropped the charge and no receipt was sent — even when\nthe buyer's email was captured on the intent. It now left-joins (the org\nname/legal-name already fall back to a generic label, and the send action\nresolves platform branding on its own), so those buyers get their receipt.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.087Z","updatedAt":"2026-06-06T17:00:17.087Z"},{"id":"c73f6b32-dda9-45b6-8be0-5877a86a86d5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"refund-sync-succeeded-emits-succeeded","type":"fixed","scope":"payments","summary":"A refund a provider settles immediately now reverts the invoice, instead of only ones confirmed by a later webhook.","body":"When `payments.refund.create` issued a refund, it emitted only\n`payments.refund.created` — which has no subscriber. The Sales side that reverts\nthe invoice listens on `payments.refund.succeeded`, which previously arrived only\nfrom a later provider webhook. So a provider that settles a refund synchronously\n(returns `succeeded` with no follow-up webhook) left the invoice still showing\nthe refunded money as paid. `refund.create` now also emits\n`payments.refund.succeeded` inline when the provider returns `succeeded`. It is\nidempotent with the webhook path (both carry the same refund id and the Sales\nsubscriber dedups on it), so a later redelivery is a no-op.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.236Z","updatedAt":"2026-06-06T17:00:17.236Z"},{"id":"87bf829c-1759-4ace-b55b-a6d15fab578a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-engine","type":"added","scope":"crm","summary":"CRM automation workflows — author multi-step action sequences and run them on demand.","body":"Added a headless CRM workflow engine. A workflow is an ordered list of action\nnodes (e.g. \"create a lead from these fields\"); each node invokes a registered\nCRM action with `{{ variable }}` placeholders resolved from the trigger input\nand prior steps' outputs. Workflows execute under the runner's own\npermissions, so an automation can never do more than the person (or AI) that\nruns it. Every run is logged with a per-step result for auditability.\n\nNew actions: `crm.workflow.create`, `crm.workflow.list`, `crm.workflow.run`\n(gated by the new `crm:workflow:manage` permission). Backed by the\n`crm_workflows` + `crm_workflow_runs` tables.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.322Z","updatedAt":"2026-06-06T17:00:17.322Z"},{"id":"425169f4-e67c-400e-b26b-9dd12be23867","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-event-triggers","type":"added","scope":"crm","summary":"CRM automations can now run automatically when a record event happens, not just on demand.","body":"CRM workflows can now be triggered by a record event. A workflow with a\n`record_event` trigger (e.g. \"when a lead is created\") runs automatically once\nit's activated — the event's data becomes the run input its steps interpolate.\n\n- New action `crm.workflow.set_status` activates or pauses a workflow; only\n  `active` workflows auto-run (any workflow can still be run manually).\n- A worker subscriber listens to a curated set of CRM lifecycle events\n  (lead/contact/company created, lead qualified, deal created/won/lost/stage\n  changed) and runs each matching active workflow.\n- Event-triggered runs execute under a least-privilege system context\n  attributed to the workflow's author, granted only the create permissions the\n  workflow's own steps need — so an automation can never escalate.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.333Z","updatedAt":"2026-06-06T17:00:17.333Z"},{"id":"20b68da7-a1f9-43fe-abca-f9a731318e53","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-update-node","type":"added","scope":"crm","summary":"CRM automations can now update existing records, not just create them.","body":"CRM workflow steps gained a second action type: **update a record**. An\nupdate step targets an existing row by id — a template like `{{ id }}` (from a\nrecord-event trigger) or `{{ <priorStep>.id }}` (a record an earlier step\ncreated) — and maps fields to value templates. This unlocks chained automations\nsuch as \"when a lead is created → update it\" or \"create a contact → then update\nthe originating lead\".\n\nThe builder now lets you pick Create or Update per step (with a \"record to\nupdate\" field for updates). Event-triggered update steps run with the\nleast-privilege `crm:<object>:update` permission, attributed to the workflow's\nauthor — same non-escalation guarantee as create steps.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:17.652Z","updatedAt":"2026-06-06T17:00:17.652Z"},{"id":"42d142ba-8ea6-4db0-9df4-1ca0336a793a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-week-time-grid","type":"changed","scope":"web","summary":"The calendar week view is now a proper hour-by-hour time grid, and overlapping events in the day and week views sit side by side instead of on top of each other.","body":"The calendar **week** view used to be a row of seven plain lists. It's now a\ntrue time grid: an hour rail down the side, seven day columns, an all-day strip\nacross the top, and a live \"now\" line on today's column — matching the day view.\n\nBoth the day and week views now lay overlapping timed events out **side by\nside** (instead of stacking them on top of one another), so a busy hour reads at\na glance. Dragging a task onto the grid snaps it to the nearest 15-minute slot\nunder the cursor.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:12:35.394Z","updatedAt":"2026-06-06T17:12:35.394Z"},{"id":"163c9b34-003d-4652-938f-d0e56a96429c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-saved-views","type":"added","scope":"crm","summary":"Save named list views (filters, columns, sort) per CRM list and switch between them.","body":"Added personal saved views for CRM lists. You can now save a contact / company\n/ deal / lead list's current search, filters, columns, and sort as a named view\nand restore it later. Views are personal (each user has their own) and\nde-duped by name per list.\n\nBacked by the new `crm_views` table and the `crm.view.{create,list,update,delete}`\nactions. This is the data + action layer; the list-view switcher UI follows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:12:35.930Z","updatedAt":"2026-06-06T17:12:35.930Z"},{"id":"0164ecbd-5ecc-476f-81a8-0ca545dfd1f0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"kb-public-vote-access","type":"fixed","scope":"support","summary":"Anonymous help-center visitors can again submit \"was this helpful?\" votes on KB articles — the public vote endpoint was incorrectly behind the sign-in gate.","body":"`support.kb.article.submit_feedback` is designed for anonymous public voting\n(its own description: \"Anonymous public visitors can vote via a hashed\nsessionToken\"), and its policy already allows unauthenticated callers. But it\nwas missing the `public` tag the API edge checks (`action.tags.includes('public')`)\nto admit anonymous requests, so every visitor vote was rejected with\n\"Sign in to call actions\" (401). Added the tag — votes now reach the action,\nmatching the sibling `convert_to_ticket` endpoint. Surfaced while bringing the\npublic-KB E2E suite back to green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:12:35.943Z","updatedAt":"2026-06-06T17:12:35.943Z"},{"id":"0ce26021-83d0-4e72-ba59-0c919566093c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"shell-motion-tokens","type":"changed","scope":"web","summary":"App-shell sidebar motion now uses the shared motion tokens for consistent, snappy transitions.","body":"The sidebar resize/collapse transition and resize-handle hover now use the shared\n`--duration-*` / `--ease-*` motion tokens instead of a hard-coded cubic-bezier, and the\nsub-nav hover eases over `--duration-quick` (120ms) instead of the abrupt 80ms — so the\nchrome's motion vocabulary is consistent across light and dark themes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:12:36.186Z","updatedAt":"2026-06-06T17:12:36.186Z"},{"id":"2e17926b-a0be-492a-92a5-bb87518967bd","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"saas-dark-mode-status-colors","type":"fixed","scope":"web","summary":"SaaS KPI / live / unsaved-changes status colors now adapt to dark mode.","body":"The SaaS KPI tile value color, the \"Live\" auto-refresh pill, and the unsaved-changes pill\nreferenced `--color-{warning,success}-700` tokens that aren't defined, so they always fell back\nto a fixed light hex that never adapted in dark mode. Repointed them to the defined, dark-aware\n`-600` tokens so they read correctly in both themes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:12:36.186Z","updatedAt":"2026-06-06T17:12:36.186Z"},{"id":"81b0ca16-e9b1-4df7-90f5-ced89c7e9b15","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"status-color-scale-dark","type":"fixed","scope":"ui","summary":"Completed the status-color scale (50–900) so status badges/banners adapt correctly in dark mode.","body":"Across ~15 admin/settings surfaces, status badges and banners referenced\n`--color-{success,warning,danger,info}` shades (`100/200/300/700/800/900`) that were never\ndefined, so they fell back to fixed light hexes that didn't adapt in dark mode. Completed the\nfull 50–900 scale for all four status hues in `globals.css`, with dark-theme overrides that flip\nthe tint steps (100–300) to dark alpha fills and lift the deep steps (700–900) to legible light\ntext. Every existing `bg-100 / text-700 / border-300`-style status chip now reads correctly in\nboth themes with no per-file changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:12:36.414Z","updatedAt":"2026-06-06T17:12:36.414Z"},{"id":"9da52e7e-7d74-4c32-81e3-e5c7f6e9be42","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-holiday-source","type":"added","scope":"calendar","summary":"Company holidays now appear on everyone's calendar as all-day markers.","body":"The calendar now includes a `holiday` source backed by the org's holiday list,\nso configured company holidays show up as all-day entries on every member's\ncalendar (and in the dashboard calendar widgets), with their own filter chip.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:32:25.528Z","updatedAt":"2026-06-06T17:32:25.528Z"},{"id":"93ebd82e-8111-4e72-b46f-5e42cee9e027","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"color-picker-primitive","type":"added","scope":"ui","summary":"Added a polished ColorPicker primitive and replaced the last native color inputs with it.","body":"Added a `ColorPicker` primitive — a swatch + hex trigger that opens a popover with a preset\npalette and a hex input, fully non-native (no OS color dialog) and theme-aware. Adopted it on\nthe expenses-category, HRM department/shift, recruitment-tag and platform error-page color\nfields, replacing the browser's native `<input type=\"color\">`. This removes the last native\nform-control type from the app.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:32:26.253Z","updatedAt":"2026-06-06T17:32:26.253Z"},{"id":"b486e393-a431-4199-9304-0fda92a44afe","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-realtime-updates","type":"added","scope":"calendar","summary":"The calendar page now updates within a couple of seconds when something it shows changes, instead of waiting for the background refresh.","body":"When a task is rescheduled, an interview moves, leave is decided, an invoice is\nissued, or anything else that feeds your calendar changes, the `/calendar` page\nnow refreshes within ~1.5 seconds instead of waiting up to a minute for the\nbackground poll.\n\nUnder the hood this is a dataless realtime signal: the server tells the\naffected users \"your calendar changed\" (carrying no event data), and each\nclient refetches its own calendar with its own permissions. Because the signal\ncarries nothing, it can never leak data — it just triggers a refresh.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:30.366Z","updatedAt":"2026-06-06T17:46:30.366Z"},{"id":"418e7431-f319-4916-a0c0-e1144faa6bce","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-builder-polish","type":"fixed","scope":"crm","summary":"Polished the CRM Automations builder — run warning, pre-save validation, error states, clearer results.","body":"Polish pass on the CRM Automations builder after a UX review:\n\n- Running an automation now shows a clear warning that it creates or updates\n  real records (it's not a dry run).\n- The builder blocks saving an automation with an invalid step — a step with\n  no fields, or an update step with no \"record to update\" — and shows an\n  inline reason, instead of letting it fail only at run time.\n- Closing the panel now clears the in-progress run state, so a previous run's\n  results can't reappear under the wrong automation when you reopen it.\n- Added an error state (with Retry) when the automations list fails to load,\n  instead of falsely showing \"no automations yet\".\n- Per-step run results now show what each step did (Create / Update) next to\n  its id, so a partial failure is legible.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:30.389Z","updatedAt":"2026-06-06T17:46:30.389Z"},{"id":"9dc1a8fc-f3e7-4d07-af70-a8a201a1ae55","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"datefield-onboarding","type":"changed","scope":"web","summary":"The onboarding-task due-date field now uses the DateField calendar popover.","body":"The onboarding-task due-date picker now uses the shared `DateField` calendar popover instead of\nthe browser's native date input, matching the milestone/project/leave date fields.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:30.625Z","updatedAt":"2026-06-06T17:46:30.625Z"},{"id":"9f6fb616-0b7a-4794-9c81-13474c849b37","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-robustness","type":"fixed","scope":"crm","summary":"Workflow runs that hit an unexpected error are now recorded as failed instead of stuck \"running\".","body":"Hardened the CRM workflow engine after a review pass:\n\n- A step whose action throws (rather than returning a clean error) no longer\n  strands the run in a perpetual \"running\" state — the run is finalized as\n  \"failed\" with the error, and the run count is still updated.\n- Field templates that resolve to a non-scalar value (an object or array) now\n  render empty instead of the meaningless `[object Object]`, so a\n  misconfigured mapping can't silently corrupt a record.\n- Added a self-loop guard: an event-triggered workflow whose step would\n  re-emit the very event that triggered it (e.g. \"when a contact is created →\n  create a contact\") is skipped and logged, preventing a runaway loop.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:30.640Z","updatedAt":"2026-06-06T17:46:30.640Z"},{"id":"cf25c008-fc02-4df9-9bdb-97f84677eb53","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"datefield-rollout-forms-2","type":"changed","scope":"web","summary":"Offer, assignment and engagement date fields now use the DateField calendar popover.","body":"The offer start date, the HRM assignment effective-from date, and the engagement\nstart / expected-end / renewal / support-ends dates now use the shared `DateField`\ncalendar popover. The compact per-milestone due-date inside the engagement form keeps\n`<Input type=\"date\">` (it sits in a dense repeating row where typing is faster).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:30.831Z","updatedAt":"2026-06-06T17:46:30.831Z"},{"id":"394bfa72-3957-48da-82fb-0c586d106dab","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"datefield-rollout-forms","type":"changed","scope":"web","summary":"More single-date form fields now use the DateField calendar popover instead of the native date input.","body":"The hire-modal start date, expense date, engagement start date, and the HRM directory\nhire/start dates now use the shared `DateField` calendar popover instead of the browser's\nnative date input, for a consistent cross-browser calendar. Bulk-entry and filter-range date\nfields keep `<Input type=\"date\">` (typing is faster there).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:30.833Z","updatedAt":"2026-06-06T17:46:30.833Z"},{"id":"07c1928a-a4b1-42cc-be1d-f9a2c0cdd579","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"public-help-center-access","type":"fixed","scope":"support","summary":"The public help center, docs, service catalog, and chat widget work again for logged-out visitors — a cluster of public read/submit actions were behind the sign-in gate.","body":"The same defect just fixed for the KB \"was this helpful?\" vote turned out to be\nsystemic: 16 more public-by-design actions (KB article get/list/search/record-view,\ncategory list, service-catalog list/get/submit, docs page/list, menu, changelog,\nstatus component/incident lists, and the widget conversation messages/end), plus\n`forms.event.track`, allowed anonymous callers in their policy but were missing\nthe `public` tag the API edge requires (`action.tags.includes('public')`). Every\nanonymous request to them returned 401 \"Sign in to call actions\", so the entire\nlogged-out help center / docs / service catalog couldn't load its data and the\nembedded chat widget couldn't poll/end conversations. Added the `public` tag to\neach (the policies already permit anon). A new contract test\n(`public-actions-contract.test.ts`) asserts every anon-reachable surface keeps\nthe tag, so this class can't silently regress again — the prior E2E tolerated the\n401 and masked it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:46:31.028Z","updatedAt":"2026-06-06T17:46:31.028Z"},{"id":"0aa95f5e-c5d5-4ff9-a2cc-091df576cc63","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-saved-views-ui","type":"added","scope":"crm","summary":"Save and switch named views on the Contacts list — filters, sort, and columns in one click.","body":"The Contacts list gained a **Views** switcher. Set up the list how you like —\nsearch, lifecycle/status filters, sort, column visibility, density — then save\nit as a named view and switch back to it anytime. Views are personal (each user\nhas their own) and can be deleted from the same menu.\n\nUnder the hood the shared data-grid gained an opt-in `viewState` /\n`onViewStateChange` API so any list can snapshot and restore its state; the\nContacts list is the first adopter, and other CRM lists can follow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T18:02:30.909Z","updatedAt":"2026-06-06T18:02:30.909Z"},{"id":"1f55de60-3e4f-41fe-bfff-00b995e11f83","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"switch-primitive-adoption","type":"changed","scope":"web","summary":"Hand-rolled toggle switches now use the shared Switch primitive.","body":"The account portal-settings, notification-preferences, and admin notification-matrix toggles\nnow use the shared Switch primitive (consistent tactile thumb, focus ring, dark-mode styling)\ninstead of hand-rolled `role=\"switch\"` buttons. Card-style \"whole row is the switch\" toggles\n(onboarding, template default) are left as-is — a Switch-in-button would be invalid markup.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T18:02:31.616Z","updatedAt":"2026-06-06T18:02:31.616Z"},{"id":"1436feac-db27-4193-8a5f-6bee0dbf659b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"auth-chrome-dark-mode-polish","type":"changed","scope":"web","summary":"Polished the sign-in chrome — dark-mode-correct card shadow and a refined, on-brand side panel.","body":"The shared auth layout (sign-in, sign-up, password reset, email verification, step-up)\nnow renders a dark-mode-correct elevation on its form card — previously the card used a\nfixed black drop-shadow that was invisible against the dark canvas. The branded side panel\nwas refreshed from the old fixed blue-violet gradient to a near-neutral cool base with a\nsoft glow tinted by the runtime accent colour, so it reads as on-brand for any operator's\ntheme instead of carrying the legacy accent. The passkey and SAML sign-in buttons pick up\nthe same dark-aware hover elevation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T18:41:08.158Z","updatedAt":"2026-06-06T18:41:08.158Z"},{"id":"5026616f-8535-4e16-8931-922e56f20b48","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-log-step-ui","type":"added","scope":"crm","summary":"The Automations builder can now add \"log an activity\" steps, not just create/update record steps.","body":"The CRM Automations builder now offers a third step type: **log an activity**.\nPick the activity kind (note / call / email / meeting / task), the record to log\nit against (by id template), and a templated subject and body — all from the\nbuilder, no code needed. Completes the operator UI for the log-activity node.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T18:41:08.841Z","updatedAt":"2026-06-06T18:41:08.841Z"},{"id":"8f673e66-3155-401a-8f8e-4563daa0bf47","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-log-activity","type":"added","scope":"crm","summary":"CRM automations can now log an activity (note, call, task…) on a record as a step.","body":"CRM workflows gained a third step type: **log an activity**. A step can record\na note / call / email / meeting / task against a contact, company, deal, or\nlead — targeting it by id (a `{{ id }}` from a record-event trigger, or\n`{{ <priorStep>.id }}` from a record an earlier step created) — with templated\nsubject and body. This enables automations like \"when a deal is won → log a\n'Won 🎉' note on the deal\" or \"create a lead → log a follow-up task on it\".\n\nEvent-triggered log steps run with the least-privilege `crm:activity:create`\npermission, attributed to the workflow's author.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T18:41:08.854Z","updatedAt":"2026-06-06T18:41:08.854Z"},{"id":"3ed2c627-a86a-4dc2-9747-a3cd9ab7ddb2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"dark-mode-shadow-tokens-projects-widgets","type":"fixed","scope":"web","summary":"Project + task-board surfaces now show their elevation correctly in dark mode.","body":"Several project and task-view surfaces used fixed black drop-shadows that were\ninvisible against the dark canvas — the portfolio KPI card, the task-board KPI\ntile hover lift, the task detail sheet's sticky header, the search/filter dock,\nthe keyboard-shortcut key caps, and the milestone cards. They now use the\ndark-aware shadow tokens so their depth renders in both light and dark themes.\nIntentional shadows on coloured elements (project avatars, the calendar \"today\"\nbadge, Kanban columns, Gantt bars, the floating bulk-action dock) were left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T18:52:21.418Z","updatedAt":"2026-06-06T18:52:21.418Z"},{"id":"bb8c6458-8edd-4b56-a38c-8fd4f98445c8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-get-update","type":"added","scope":"crm","summary":"Automations can now be loaded and edited — new get + update actions back the full builder.","body":"Added `crm.workflow.get` (returns a workflow's full trigger + step definition)\nand `crm.workflow.update` (edits its name, trigger, and/or steps). These back\nthe new full-page automation builder so an existing automation can be reopened,\nre-edited, and saved — not just created once.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.463Z","updatedAt":"2026-06-06T19:55:57.463Z"},{"id":"508874de-4b3a-447c-9c1c-45d87b9aeaaa","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-office-holiday-management","type":"added","scope":"hrm","summary":"Company holidays can now be classified, edited, deleted, and marked as office-closure / optional / observance days.","body":"Company holidays gained real management + classification:\n\n- **Edit and delete** holidays (previously they could only be created and\n  listed — there was no way to fix a typo or remove one).\n- **Classify** each holiday by `type` (public / bank / regional / optional /\n  observance / half-day) and an optional **religion** tag, and mark whether it's\n  an **office closure** (vs a non-closing observance), **half-day**, or an\n  **optional/restricted** day employees individually elect.\n- **Keep/exclude** without deleting via an active flag, so a holiday can be\n  switched off and back on.\n\nThe unified calendar now reflects the distinction: office closures read as\ngreen all-day markers, observances read neutral, and optional/restricted days\nare labelled accordingly.\n\nThis is the foundation for the upcoming public-holiday catalog (browse by\ncountry/region/religion) and for excluding office closures from leave-day\ndeductions — both land in following phases.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.670Z","updatedAt":"2026-06-06T19:55:57.670Z"},{"id":"65b22f21-e9bd-480d-a60e-8f5859bff8e4","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"client-portal-link-self-heal","type":"fixed","scope":"crm","summary":"A client invited to the portal can no longer end up with a working login but a permanently empty portal when the post-accept link step fails.","body":"The signup/invite screens accept a portal invitation via Better-Auth (which\nmarks the invitation `accepted`) and then call `iam.invitation.complete`\n**best-effort** (`.catch`) to emit `iam.invitation.accepted` — the event that\nfires the CRM linker which back-fills `contacts.user_id`. If that best-effort\ncall failed (a transient blip), the client got a membership but no contact\nlink, and `iam.invitation.claim_pending` couldn't recover it (it only processes\n*pending* invites, and this one was already `accepted`). The result was a\nclient with a valid login but a permanently empty portal (`resolveClientScope`\nreturns null without `contacts.user_id`), signalled only by a swallowed\n`console.warn`.\n\nNew `crm.contact.claim_portal_pending` self-heals this: for the signed-in\ncaller, it links any contact whose `pending_invitation_id` points at an\n**accepted** invite addressed to the caller's own email — setting\n`contacts.user_id`, clearing `pending_invitation_id`, reactivating the client\nmembership, and promoting the user to `type=client`. It's idempotent,\nself-scoped, race-safe (compare-and-set on `user_id IS NULL`), and runs from\n`/api/me` on every authenticated request alongside the existing pending-invite\nclaim, so a stuck portal recovers on the client's next page load.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.671Z","updatedAt":"2026-06-06T19:55:57.671Z"},{"id":"5d91a6a4-f6cb-414b-96d6-9a17cf5d39ef","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-sign-emit-once","type":"fixed","scope":"hrm","summary":"Signing a contract/NDA/joining letter twice at once (double-click) can no longer send a duplicate confirmation email or double-fire onboarding automation.","body":"The public e-sign actions (`hrm.contract.public.sign`, `hrm.nda.public.sign`,\n`hrm.joining_letter.public.acknowledge`) read the document, flipped it to\n`signed` with an id-only UPDATE, then consumed the single-use token and emitted\n`hrm.<kind>.signed`. The token consume is an atomic single-use CAS, but the\nre-render + event ran unconditionally — so two concurrent submits (a double-click\nor an SPA retry) both reached the emit, producing a duplicate \"document signed\"\nemail, a double pipeline-engine onboarding sync, and a PDF re-render race. The\nemit + re-render are now gated on the result of the token consume (which returns\nnull for the submit that lost the single-use race), so exactly one winner emits;\nthe loser returns the existing `signedAt` as `alreadySigned`. The document-state\nwrite stays idempotent.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.823Z","updatedAt":"2026-06-06T19:55:57.823Z"},{"id":"d0258647-1325-4847-a966-174f0c21d99d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-send-email","type":"added","scope":"crm","summary":"CRM automations can now send an email as a step — e.g. \"when a deal is won → email the team\".","body":"CRM workflows gained a fourth step type: **send an email**. A step sends to a\ntemplated recipient address with a templated subject and body — so an automation\ncan notify on a lifecycle event (\"when a lead is created → email the owner\") or\nas part of a multi-step flow.\n\nThe send runs through the unified email pipeline (`email.outbound.send`) — with\nsuppression, rate limiting, and audit — under a system context attributed to the\nworkflow's author, which the engine elevates with `email:outbound:send` for that\nstep only (no user role holds that permission directly).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.889Z","updatedAt":"2026-06-06T19:55:57.889Z"},{"id":"575bc270-408a-478d-bee1-7fa02f33f2d7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch2","type":"changed","scope":"web","summary":"Clients hub, activity feed, and portal settings/support use the shared shimmer Skeleton.","body":"More loading-state consistency: the clients hub list, the activity feed table, the\nportal notification-settings page, and the portal support-thread page replaced\ntheir hand-rolled `animate-pulse` loading blocks with the shared `Skeleton`\nprimitive (crafted gradient shimmer, dark-mode-correct). Several of these files\nalready used Skeleton elsewhere, so this removes a within-file inconsistency.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:58.120Z","updatedAt":"2026-06-06T19:55:58.120Z"},{"id":"da8fbf16-dc1c-4de8-80ec-a930b14929c0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-page","type":"changed","scope":"crm","summary":"CRM Automations is now a full-page visual builder (Trigger → steps flow), not a slide-over.","body":"Replaced the Automations slide-over with a proper full-page builder at\n**CRM → Automations** (`/crm/automations`). It's a three-pane workspace:\n\n- **Left** — your automations, click to open and edit.\n- **Centre** — a vertical node flow: a Trigger node at the top, then each\n  action step as a connected card (create / update / log an activity), with an\n  \"Add step\" affordance and per-step remove.\n- **Right** — a config panel for the selected trigger or step.\n\nA toolbar carries the name, Activate/Pause, Run (with a per-step result), and\nSave. Existing automations load fully into the builder for editing — not just\ncreating once. Added an \"Automations\" entry to the CRM sidebar.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.893Z","updatedAt":"2026-06-06T19:55:57.893Z"},{"id":"c84415f8-9c96-4b2c-b42e-60f136962159","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-list-avatars","type":"changed","scope":"crm","summary":"Companies and Leads lists now use the same colored monogram avatars as Contacts.","body":"Unified the record monograms across the CRM lists. Companies (previously a flat\ngrey square) and Leads (previously a single warning-tinted circle) now use the\nshared `Avatar` — deterministic, per-name colored initials — matching the\nContacts list. Small change, but the lists now read as one consistent, modern\nsystem instead of three different monogram styles.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.920Z","updatedAt":"2026-06-06T19:55:57.920Z"},{"id":"f3616379-47d5-4317-aaf7-7de4fc5e1264","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-email-step-ui","type":"added","scope":"crm","summary":"The Automations builder can now add \"send an email\" steps with a recipient, subject, and body.","body":"The CRM Automations builder gained a \"Send an email\" step type. Pick a\ntemplated recipient (`{{ email }}`), subject, and body — all from the builder.\nCompletes the operator UI for the send-email node; the flow shows it with an\nenvelope icon and validates that a recipient, subject, and body are set.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.926Z","updatedAt":"2026-06-06T19:55:57.926Z"},{"id":"7e6440c8-480a-4f4f-89b1-b2f39d78ffc7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"recruitment-offer-respond-cas","type":"fixed","scope":"recruitment","summary":"Accepting or declining an offer twice at once (double-click) can no longer double-fire the candidate email or the onboarding pipeline sync.","body":"The public offer accept/decline actions (`recruitment.offer.public.accept` /\n`.decline`) read the offer's status, then flipped it with an id-only UPDATE and\nemitted `recruitment.offer.accepted` / `.declined` unconditionally. The token\nconsume was id-only too (no `used_at IS NULL` guard on accept). Under Postgres'\ndefault isolation, two concurrent submits both read `status='sent'` before either\ncommitted, so both flipped and both emitted — doubling the candidate-facing email\nand the pipeline-engine onboarding sync (offer.accepted drives the\n`offer → offer_accepted` stage transition). Each flip is now a compare-and-set on\n`status='sent'` with `RETURNING`; only the winner consumes the token + emits, and\nthe loser returns the recorded `respondedAt` idempotently. Mirrors the HRM\npublic-sign emit-once fix.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.941Z","updatedAt":"2026-06-06T19:55:57.941Z"},{"id":"07be13a1-8082-40e7-b2fd-14e0de761c16","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"portal-loading-skeletons","type":"changed","scope":"web","summary":"Client portal pages use the shared shimmer Skeleton for loading states.","body":"The client portal pages (account overview, projects, statement, support, and the\npublic company portal) replaced their hand-rolled `animate-pulse` loading blocks\nwith the shared `Skeleton` primitive. It uses a crafted gradient shimmer sweep\n(Stripe/Linear-style) instead of the plain opacity pulse, is dark-mode-correct,\nand keeps loading placeholders visually consistent with the rest of the app.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:57.949Z","updatedAt":"2026-06-06T19:55:57.949Z"},{"id":"172384fa-eaa9-438b-9768-39f754d22da5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch1","type":"changed","scope":"web","summary":"More pages adopt the shared shimmer Skeleton primitive for loading states.","body":"Continuing the loading-state consistency pass: 14 more surfaces (CRM activity\ntimeline, hiring team, HRM document history, onboarding tasks, recruitment flow\nsettings, several project/task widgets, the support inbox, and the form-builder\neditor) replaced their hand-rolled `animate-pulse` loading blocks with the shared\n`Skeleton` primitive — a crafted gradient shimmer that's dark-mode-correct and\nconsistent across the app. Real pills, message bubbles, and editor textareas that\nmerely share a neutral background were correctly left untouched.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:58.047Z","updatedAt":"2026-06-06T19:55:58.047Z"},{"id":"b5749c6e-427e-4aa1-a5aa-90b8a0719630","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch3","type":"changed","scope":"web","summary":"More admin/detail pages use the shared shimmer Skeleton for loading states.","body":"Continued loading-state consistency: the new-project sheet, the HRM joining-pack\nPDF preview, the payroll overview, the recruitment job detail, and the SaaS health\nand changelog admin pages replaced their remaining hand-rolled `animate-pulse`\nloading blocks with the shared `Skeleton` primitive (crafted gradient shimmer,\ndark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T19:55:58.125Z","updatedAt":"2026-06-06T19:55:58.125Z"},{"id":"3fecdf6e-52b9-4831-b18c-2d4ab7b2ad92","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-activities-modernized","type":"changed","scope":"crm","summary":"The CRM Activities timeline adopts the standard page header and shows actor avatars.","body":"Modernized the CRM Activities page to match the rest of the CRM: it now uses the\nstandard `PageHeader` (eyebrow + title + description, with the type filter and\n\"Log activity\" in the header actions) instead of a one-off heading, and each\ntimeline entry shows a colored monogram avatar next to the person who logged it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:04.885Z","updatedAt":"2026-06-06T21:06:04.885Z"},{"id":"67d96eaa-18ef-4d8a-8f25-264d48b22cf8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-deal-card-avatar","type":"changed","scope":"crm","summary":"Deal kanban cards show a company/contact monogram, matching the rest of the CRM.","body":"Deal cards on the pipeline board now show a small colored monogram next to the\ncompany/contact line — the same `Avatar` treatment used across the CRM lists and\nrecord pages, so the board reads as part of one consistent system.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.490Z","updatedAt":"2026-06-06T21:06:05.490Z"},{"id":"0cf615a0-8fb6-48b7-ac9d-4e462cdae71f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-record-avatars","type":"changed","scope":"crm","summary":"Contact, lead, and deal detail pages now lead with a colored monogram avatar.","body":"The CRM record detail pages (contact, lead, deal) now show a colored monogram\navatar in their header, matching the lists — so a record reads as the same\nentity from list to detail. The shared `RecordView` primitive gained an\noptional `leading` slot for this (additive; existing record pages are\nunaffected).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.500Z","updatedAt":"2026-06-06T21:06:05.500Z"},{"id":"10439d8e-d409-4d1f-9069-7454cc906d93","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch4","type":"changed","scope":"web","summary":"Projects module list/detail pages use the shared shimmer Skeleton for loading states.","body":"Loading-state consistency across the Projects module: the categories, projects\nlist, labels, milestones, and template (list + detail) pages replaced their\nhand-rolled `animate-pulse` loading blocks with the shared `Skeleton` primitive\n(crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.991Z","updatedAt":"2026-06-06T21:06:05.991Z"},{"id":"28d2b976-34d6-4115-bf7c-33a108f8ff1c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-condition","type":"added","scope":"crm","summary":"CRM automations can branch on a condition — \"only continue if …\".","body":"CRM workflows gained a **condition** step — a gate that checks a value against\nanother (`equals` / `is not` / `contains` / `>` / `<`, both templated) and, if\nit's false, stops the run there (the steps below it are skipped). The run still\ncounts as a success — the automation simply decided not to act.\n\nThis unlocks targeted automations like \"when a deal is won → only if amount > 10000\n→ send an email\", or \"on lead created → only if status equals qualified → create\na contact\". Available as both an engine node and a step in the visual builder.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.769Z","updatedAt":"2026-06-06T21:06:05.769Z"},{"id":"cfaf9507-f977-48c8-a1eb-e66bbc909916","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-leave-day-holiday-weekend-exclusion","type":"added","scope":"hrm","summary":"Office holidays (and optionally weekends) can now be excluded from leave-day deductions.","body":"Leave requests can now be counted in **working days** instead of raw calendar\ndays. A new per-org leave setting controls two independent toggles:\n\n- **Exclude office-closure holidays** — leave that spans a company holiday no\n  longer deducts that day from the balance.\n- **Exclude weekend days** — leave over the weekend doesn't deduct those days\n  (with a configurable weekend, e.g. Fri+Sat for Gulf work-weeks).\n\nBoth are **off by default**, so existing orgs' leave balances are completely\nunchanged until an admin opts in — and the change only affects leave requests\ncreated after it's enabled, never historical ones. Half-day adjustments now\ncorrectly apply only when the boundary day is actually counted.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.971Z","updatedAt":"2026-06-06T21:06:05.971Z"},{"id":"56e27f09-fb97-47de-9bd8-777c0a5c3ff2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-saved-views-rollout","type":"added","scope":"crm","summary":"Saved views now work on the Companies and Leads lists too, not just Contacts.","body":"Extended the Views switcher to the **Companies** and **Leads** lists. As on\nContacts, you can set up a list (search, filters, sort, columns, density), save\nit as a named personal view, and switch back to it anytime. Each list captures\nits own filters in the view — Companies (tier / size), Leads (status / score /\nsearch) — so applying a view restores the whole list state. Saved views are now\navailable across every CRM table list.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.781Z","updatedAt":"2026-06-06T21:06:05.781Z"},{"id":"3eb1c9ac-1c4a-4f89-a527-7684373d6630","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"demo-mode-badge","type":"added","scope":"web","summary":"A subtle \"Demo\" chip appears in the topbar when a workspace contains demo data.","body":"Workspaces that contain demo data now show a small \"Demo\" chip in the topbar\nlinking straight to Settings → Organization → Demo data. It renders nothing on a\nreal workspace (and is easy to crop out of a screenshot), so it's only there\nwhen there's demo data to manage or remove.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.782Z","updatedAt":"2026-06-06T21:06:05.782Z"},{"id":"0d66c732-98fe-4852-abd2-ae62af514e0c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-holidays-settings-ui","type":"added","scope":"hrm","summary":"HR settings has a new Holidays tab to add, classify, edit, and exclude company holidays and control how they affect leave.","body":"HR Settings → **Holidays** is a new tab that puts the holiday actions behind a\nUI for the first time:\n\n- **Manage holidays** — add, edit, and delete company holidays, each with a\n  type (public / bank / regional / optional / observance / half-day), an\n  optional religion, region, and flags for office-closure, paid, half-day, and\n  optional/restricted. \"Exclude\" keeps a holiday on file but drops it from the\n  calendar and leave math; \"Include\" brings it back. Search + show-excluded.\n- **How holidays affect leave** — a card with the two opt-in toggles (exclude\n  office-closure holidays / exclude weekends from leave) and a weekend-day\n  picker, so admins can turn on working-day leave counting without touching the\n  API. Changes save instantly and only affect new leave requests.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.969Z","updatedAt":"2026-06-06T21:06:05.969Z"},{"id":"b476e2ff-3a06-4808-b63e-c5b6ec46219f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-holiday-catalog-browse-import","type":"added","scope":"hrm","summary":"A built-in public-holiday catalog you can browse by country, region, religion, and type, then import as company holidays.","body":"Phase 3 of the holiday-management spec. Two new actions back a\n\"browse → keep/exclude → import\" flow over a curated public-holiday catalog:\n\n- `hrm.holiday.browse_catalog` — filter the catalog for a year by country\n  (ISO 3166-1), religion, holiday type, and a name query; each candidate is\n  flagged if the org already has it, and the available filter facets come back\n  too.\n- `hrm.holiday.import_from_catalog` — import the selected holidays as company\n  holidays for the year (idempotent; public/bank/regional default to\n  office-closure, observances don't).\n\nThe catalog ships as a conservative, license-clean fixed-date core (major\ncountries' national + fixed religious days) behind a pluggable source — broad\ncountry coverage and moving holidays (Easter/Eid/Diwali) are a planned add via\nan external dataset. No new tables: imports land in the existing holiday store\nand immediately flow to the calendar and (when enabled) leave deductions.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:16:44.008Z","updatedAt":"2026-06-06T21:16:44.008Z"},{"id":"5ef8dd44-86f0-44ab-b996-9acf0c174705","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch8","type":"changed","scope":"web","summary":"Recruitment applications, talent, and bulk-recruit pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the recruitment applications board, the talent\npool list, and the bulk-recruit page replaced their hand-rolled `animate-pulse`\nloading blocks with the shared `Skeleton` primitive (crafted gradient shimmer,\ndark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:16:44.282Z","updatedAt":"2026-06-06T21:16:44.282Z"},{"id":"19585cf5-8d6f-4f02-9f82-577186243f24","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch5","type":"changed","scope":"web","summary":"Task conversation, recruitment jobs list, and currencies settings use the shared Skeleton.","body":"More loading-state consistency: the task conversation panel, the recruitment jobs\nlist, and the currencies settings table replaced their hand-rolled `animate-pulse`\nloading blocks with the shared `Skeleton` primitive (crafted gradient shimmer,\ndark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:05.991Z","updatedAt":"2026-06-06T21:06:05.991Z"},{"id":"303ee96f-ea78-476d-a485-21b11b28fa39","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch7","type":"changed","scope":"web","summary":"AI settings pages (audit, providers, routing) use the shared shimmer Skeleton.","body":"More loading-state consistency: the AI audit feed, AI providers list, and AI\nrouting rules pages replaced their hand-rolled `animate-pulse` loading blocks\nwith the shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:06.099Z","updatedAt":"2026-06-06T21:06:06.099Z"},{"id":"f171d54d-ce4b-42b9-b872-9d5e00009971","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch6","type":"changed","scope":"web","summary":"Billing and domain-setup settings pages use the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the billing settings page and the domain-setup\nverification page replaced their hand-rolled `animate-pulse` loading blocks with\nthe shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:06:06.087Z","updatedAt":"2026-06-06T21:06:06.087Z"},{"id":"f2548db3-e8f7-4ee9-bcdc-916af40f2d0c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-delete","type":"added","scope":"crm","summary":"Automations can now be deleted from the builder.","body":"Closed a gap in the CRM automation builder: you can now **delete an automation**.\nA Delete button in the builder toolbar (with a confirm step) removes it — it\nstops running on its trigger and drops out of your list. Backed by the new\n`crm.workflow.delete` action (soft delete, so nothing is hard-erased).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T21:16:43.267Z","updatedAt":"2026-06-06T21:16:43.267Z"},{"id":"1eaf537e-5952-46d0-b7e9-cc1d74495ebc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-automations-delay-ui","type":"added","scope":"crm","summary":"The Automations builder can now add \"Wait\" (delay) steps.","body":"The CRM Automations builder gained a **Wait** step. Pick an amount and a unit\n(minutes / hours / days) and the automation pauses there, continuing with the\nsteps below once the wait elapses. Shown with a clock icon in the flow.\nCompletes the builder UI for all six node types: create, update, log, email,\ncondition, and wait.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:54.328Z","updatedAt":"2026-06-06T22:26:54.328Z"},{"id":"f84d54e5-f7af-48d9-a892-ca2e2eb19d8c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-delay","type":"added","scope":"crm","summary":"CRM automations can wait — a delay step pauses the run for minutes, hours, or days, then continues.","body":"CRM workflows gained a **delay** step. When a run reaches it, the automation\npauses and resumes the rest of its steps after the chosen wait (minutes up to\nabout a year) — so you can build flows like \"when a lead is created → wait 1\nday → send a follow-up email\" or \"create a contact → wait 10 minutes → log a\ncheck-in task\".\n\nThe wait is durable: a paused run is persisted as `waiting` (no held\nconnection) and a worker cron resumes it once due, continuing under the same\nleast-privilege, author-attributed context as the rest of the run. This\ncompletes the workflow engine's node set (create / update / log / email /\ncondition / delay).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.025Z","updatedAt":"2026-06-06T22:26:55.025Z"},{"id":"b8442597-3193-4cc0-a4f9-6c5bf1a4f9e1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-sidebar-grouping","type":"changed","scope":"crm","summary":"The CRM sidebar is now grouped — Pipeline, Records, and Engage — instead of a flat link list.","body":"Reorganized the CRM module sidebar from a flat list of seven links into three\nlabeled groups, matching the modern grouped-nav pattern used elsewhere in the\napp: **Pipeline** (Overview · Leads · Deals), **Records** (Contacts ·\nCompanies), and **Engage** (Activities · Automations). Same destinations,\nclearer structure — the funnel, the people/companies database, and the\nengagement tools each read as their own section.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.043Z","updatedAt":"2026-06-06T22:26:55.043Z"},{"id":"1d15b2f9-2f00-448a-b613-cc4159218f29","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-holiday-catalog-import-ui","type":"added","scope":"hrm","summary":"The Holidays settings tab can now browse the public-holiday catalog by country, religion, and type and import holidays in bulk.","body":"HR Settings → Holidays gains an **Import from catalog** dialog: filter the\nbuilt-in public-holiday catalog by country, religion, holiday type, and year,\nsearch by name, then tick the holidays you observe and import them in one\nclick. Holidays you've already imported show as such and can't be duplicated.\nImported holidays immediately appear in the list, on the calendar, and (when\nenabled) drop out of leave deductions — completing the browse → keep/exclude →\nimport flow end to end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.260Z","updatedAt":"2026-06-06T22:26:55.260Z"},{"id":"9d59cb81-3cdc-4e73-89f3-c5671b8bb2a0","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-workflow-run-history","type":"added","scope":"crm","summary":"See an automation's run history — status, timing, and errors — from the builder.","body":"The CRM Automations builder gained a **Runs** view. Open it from a selected\nautomation's toolbar to see its recent runs — each with a status badge\n(succeeded / failed / waiting), when it started, how many steps ran, and any\nerror. Backed by the new `crm.workflow.runs` action, so automations are now\nobservable, not just runnable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.269Z","updatedAt":"2026-06-06T22:26:55.269Z"},{"id":"0a7c80ac-2e72-4fee-a1bf-7fbdc8e27df3","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-optional-holiday-elections","type":"added","scope":"hrm","summary":"Employees can elect optional/restricted holidays up to an annual quota; an elected day drops out of their leave deductions.","body":"The optional/restricted-holiday flow (the India RH / \"floating holiday\"\npattern). Holidays marked **optional** form a pool that employees pick from up\nto a per-org annual quota — the office stays open, but each elector gets that\nday as a personal paid day off.\n\n- New `hrm_holiday_elections` table + a per-org `optional_holiday_quota_per_year`\n  setting (default 2).\n- Actions: `hrm.holiday.list_optional` (the electable pool + the employee's\n  elections + remaining quota), `hrm.holiday.elect`, and `hrm.holiday.unelect` —\n  self-service for the calling user, with managers able to act for a report.\n  Quota and electability are enforced; elections are idempotent.\n- An employee's elected optional holidays are excluded from *their* leave-day\n  deductions (when holiday-exclusion is on), so a leave request spanning one no\n  longer charges that day.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.284Z","updatedAt":"2026-06-06T22:26:55.284Z"},{"id":"26a4aa32-c9d0-4404-9424-4d3bc6a8ac2d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-optional-holiday-self-service","type":"added","scope":"hrm","summary":"Employees can pick their optional/restricted holidays for the year from a new card on their HR profile page.","body":"The self-service half of optional/restricted holidays. \"My profile\" (/hrm/me)\ngains an **Optional holidays** card listing the holidays the org has marked\noptional for the current year. Each shows its date; employees \"Take this day\"\nor \"Remove\" it, with a running picked/quota count and a clear message when the\nquota is used up. The card auto-hides for orgs that don't configure any\noptional holidays. Picks immediately reflect on the calendar and in leave-day\nmath.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.524Z","updatedAt":"2026-06-06T22:26:55.524Z"},{"id":"79554978-661c-4620-b426-3b6b75f47120","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"kanban-add-card","type":"added","scope":"crm","summary":"Add a deal straight into a stage column from the board, pre-set to that stage.","body":"The deal board's columns now have an inline **\"+ Add deal\"** footer — clicking\nit opens the create flow with that column's **stage pre-selected**, so you add a\ndeal directly where it belongs instead of picking the stage manually. Backed by\na new opt-in `renderColumnFooter` slot on the shared Kanban primitive, so any\nboard can add a per-column footer the same way.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.525Z","updatedAt":"2026-06-06T22:26:55.525Z"},{"id":"724b6a84-2e90-4a06-8244-642c34285f65","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch10","type":"changed","scope":"web","summary":"Sales tax-rates, products, and quotations pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the sales tax-rates, products catalog, and\nquotations list replaced their hand-rolled `animate-pulse` loading blocks with the\nshared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.547Z","updatedAt":"2026-06-06T22:26:55.547Z"},{"id":"67416a4e-ffc0-45f7-8fae-85217a0d8722","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch13","type":"changed","scope":"web","summary":"HRM time, onboarding, and clock-policy pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the HRM time-tracking page, the onboarding board, and\nthe clock-policy settings tab replaced their hand-rolled `animate-pulse` loading blocks\nwith the shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.775Z","updatedAt":"2026-06-06T22:26:55.775Z"},{"id":"110ffe3d-a977-4bc8-ac00-bb48f4fd746c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch15","type":"changed","scope":"web","summary":"What's-new, onboarding welcome, and HRM document-sign pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the public what's-new changelog page, the onboarding\nwelcome page, and the HRM public document-sign page replaced their hand-rolled\n`animate-pulse` loading blocks with the shared `Skeleton` primitive (crafted gradient\nshimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.822Z","updatedAt":"2026-06-06T22:26:55.822Z"},{"id":"a46052f2-2e7f-47e8-9e82-59eb375566ca","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch11","type":"changed","scope":"web","summary":"Sales invoices, recurring, and credit-notes lists use the shared shimmer Skeleton.","body":"More loading-state consistency: the sales invoices list, recurring invoices list, and\ncredit-notes list replaced their hand-rolled `animate-pulse` loading blocks with the\nshared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct). This\ncompletes the sales-module list surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.562Z","updatedAt":"2026-06-06T22:26:55.562Z"},{"id":"82b7835f-5290-40c8-900e-5b0b8e494afb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch12","type":"changed","scope":"web","summary":"Payroll payslip, reports, and runs pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the payroll payslip detail (incl. the PDF preview),\nthe payroll reports page, and the payroll runs list replaced their hand-rolled\n`animate-pulse` loading blocks with the shared `Skeleton` primitive (crafted gradient\nshimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.767Z","updatedAt":"2026-06-06T22:26:55.767Z"},{"id":"2c34d35a-5379-4d86-9d13-a468fe137b83","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch14","type":"changed","scope":"web","summary":"HRM employee profile page uses the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the HRM employee profile/detail page replaced its\nhand-rolled `animate-pulse` loading blocks (header, stat tiles, panels, and the\ninline lists) with the shared `Skeleton` primitive (crafted gradient shimmer,\ndark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.786Z","updatedAt":"2026-06-06T22:26:55.786Z"},{"id":"9388334e-96ff-4109-a14c-3563c2385115","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-holiday-catalog-full-coverage","type":"added","scope":"hrm","summary":"The holiday catalog now covers 206 countries plus moving holidays (Eid, Diwali, Easter) via the date-holidays dataset.","body":"The \"Import from catalog\" browser now spans **206 countries** and their\nsubdivisions, and computes **movable / lunar holidays** the curated core\ncouldn't — Islamic (Eid, Ramadan), Hindu (Diwali, Holi), Easter-relative, lunar\nnew year, and more — on the correct date for the chosen year. Lunar dates that\ndepend on moon-sighting are flagged **tentative** in the picker so admins can\nconfirm the official local date.\n\nThis layers the `date-holidays` library as a second source behind the catalog's\npluggable seam (the curated, license-free core still serves the religion-only\nbrowse and wins on any shared date). `date-holidays`' code is ISC; its holiday\n**data is CC BY-SA 3.0**, so the picker now shows the required attribution\n(\"Holiday data © contributors, CC BY-SA 3.0, via date-holidays / Wikipedia\").\n\n## Note\n\nThe bundled holiday dataset is CC BY-SA 3.0 (attribution + share-alike on the\ndatabase). We attribute it in-product and do not redistribute a modified\ndataset; legal should confirm the attribution wording.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.825Z","updatedAt":"2026-06-06T22:26:55.825Z"},{"id":"78cc10d0-0317-485d-b56c-4f02c3e44103","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch9","type":"changed","scope":"web","summary":"Sales approvals, settings, and payments pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the sales approvals queue, sales settings page, and\npayments list replaced their hand-rolled `animate-pulse` loading blocks with the\nshared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:56.013Z","updatedAt":"2026-06-06T22:26:56.013Z"},{"id":"4c934c70-b46e-4517-9f70-3ca0ec992d86","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-catalog-easter-holidays","type":"added","scope":"hrm","summary":"The holiday catalog now includes movable Easter holidays (Good Friday, Easter Monday, Ascension, Whit Monday) for major countries.","body":"The public-holiday catalog gained its first **movable feasts** — computed per\nyear from Easter via the deterministic Meeus/Jones/Butcher algorithm (no\nexternal dataset, no licensing strings). Good Friday, Easter Monday, Ascension,\nand Whit Monday now show up for the UK, Germany, France, Canada, and Australia\n(plus Easter Sunday / Good Friday as observances), so importing a country's\nholidays for a given year picks up its Easter-relative public holidays on the\ncorrect dates. This exercises the catalog's \"computed source\" seam ahead of any\nbroader external dataset.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.816Z","updatedAt":"2026-06-06T22:26:55.816Z"},{"id":"dd48cb59-3cb2-4387-a43b-8f1b89e0902f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch17","type":"changed","scope":"web","summary":"Public quotation and offer pages use the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the public quotation-view page and the public\nrecruitment offer-accept page replaced their hand-rolled `animate-pulse` loading\nblocks with the shared `Skeleton` primitive (crafted gradient shimmer,\ndark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.821Z","updatedAt":"2026-06-06T22:26:55.821Z"},{"id":"7d0e9866-ee7f-4904-8e5e-e76cfa32b2ac","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch16","type":"changed","scope":"web","summary":"CRM activities and expenses (list + reports) pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the CRM activities timeline, the expenses list, and the\nexpenses reports page replaced their hand-rolled `animate-pulse` loading blocks with the\nshared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:55.822Z","updatedAt":"2026-06-06T22:26:55.822Z"},{"id":"c6c8f5d8-ed91-4735-b56b-8bc431d67d33","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch18","type":"changed","scope":"web","summary":"Payroll run detail page uses the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the payroll run detail page replaced its hand-rolled\n`animate-pulse` loading blocks with the shared `Skeleton` primitive (crafted gradient\nshimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T22:26:56.006Z","updatedAt":"2026-06-06T22:26:56.006Z"},{"id":"c62b3b3d-7497-4bbc-ad25-bc7954d7c81f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"calendar-source-limit-fix","type":"fixed","scope":"calendar","summary":"Calendar sources (tasks, interviews, deals, activities, and more) no longer fail with \"Invalid input\" and now show their events.","body":"Eleven calendar sources — tasks, interviews, offers, reminders, 1:1s, notices,\nCRM activities, projects, cycles, subscription renewals, and deal close dates —\nwere silently failing with \"Invalid input\" and contributing nothing to the\ncalendar. The aggregator asked each underlying list for up to 500 rows, but\nthose actions cap their `limit` at 200 (most), 100 (reminders, 1:1s), or 50\n(notices), so the request was rejected before it ran. Each source now requests\nwithin its action's limit, so those events appear on the calendar again.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:09.520Z","updatedAt":"2026-06-06T23:08:09.520Z"},{"id":"2c2da3b4-fa53-4f87-8f34-ae98769d7a5b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-list-record-peek","type":"changed","scope":"crm","summary":"Clicking a contact, company, or lead opens a right-side peek instead of navigating away.","body":"Clicking a row in the **Contacts**, **Companies**, or **Leads** lists now opens\na right-side **peek panel** with the record's key fields and its recent activity\n— so you can glance at a record without losing your place in the list (the Twenty\nside-peek pattern, which Deals already used). Each peek has an \"Open full\nrecord →\" link to the full page (the clients hub for companies). Lists now feel\nlike a fast database you skim, not a series of full-page hops.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.065Z","updatedAt":"2026-06-06T23:08:10.065Z"},{"id":"629a5825-ff97-4069-a603-707494f70a52","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"crm-grid-inline-edit","type":"added","scope":"crm","summary":"Edit fields directly in the Leads and Companies lists, like the Contacts list already allows.","body":"Brought edit-in-place to the **Leads** and **Companies** lists, matching the\nContacts list. Click a cell to edit it without opening the record:\n\n- Leads: company, title, and status (a dropdown) are now inline-editable.\n- Companies: industry and tier (a dropdown) are now inline-editable.\n\nEdits save optimistically (the cell updates instantly, reconciles on the\nserver, and reverts with a toast on failure) and are gated by the relevant\nupdate permission. Uses the shared `EditableTextCell` / `EditableSelectCell`\nprimitives, so the behavior is consistent across grids.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.066Z","updatedAt":"2026-06-06T23:08:10.066Z"},{"id":"6f61f442-22a8-4f40-afdd-d6bd10dabd02","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch20","type":"changed","scope":"web","summary":"IAM hub, notices, and catalogs settings pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the IAM/access hub, the notices settings page, and the\ncatalogs settings page replaced their hand-rolled `animate-pulse` loading blocks with\nthe shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.308Z","updatedAt":"2026-06-06T23:08:10.308Z"},{"id":"5fd2a98c-ea05-4835-95a1-282ccd4bdecf","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch19","type":"changed","scope":"web","summary":"Platform email admin pages (overview, flows, logs, routing) use the shared shimmer Skeleton.","body":"More loading-state consistency: the SaaS platform email admin pages — overview, flows,\nlogs, and routing — replaced their hand-rolled `animate-pulse` loading blocks with the\nshared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.309Z","updatedAt":"2026-06-06T23:08:10.309Z"},{"id":"1398ac2b-2915-4bcb-80eb-85633839f8bc","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch21","type":"changed","scope":"web","summary":"Project settings and \"my work\" pages use the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the project settings page and the projects \"my work\"\npage replaced their hand-rolled `animate-pulse` loading blocks with the shared\n`Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.559Z","updatedAt":"2026-06-06T23:08:10.559Z"},{"id":"65b2f385-f023-47d9-af03-4d1e4c9a5d8b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch23","type":"changed","scope":"web","summary":"Support ticket, credit-note, payroll group, and client-project pages use the shared Skeleton.","body":"More loading-state consistency: the support ticket detail, the credit-note detail, the\npayroll group detail, and the client-portal project detail pages replaced their\nhand-rolled `animate-pulse` loading blocks with the shared `Skeleton` primitive (crafted\ngradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.559Z","updatedAt":"2026-06-06T23:08:10.559Z"},{"id":"5db2f94b-04e3-4370-b999-ae4ced377e8a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch22","type":"changed","scope":"web","summary":"Recruitment talent profile and recruit-wizard pages use the shared shimmer Skeleton.","body":"More loading-state consistency: the recruitment talent/candidate profile page and the\nrecruit-wizard page replaced their hand-rolled `animate-pulse` loading blocks (including\nthe inline label placeholders) with the shared `Skeleton` primitive (crafted gradient\nshimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.560Z","updatedAt":"2026-06-06T23:08:10.560Z"},{"id":"f3e237b9-b60c-49e7-8574-05f314c0435f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch24","type":"changed","scope":"web","summary":"Root dashboard and task-widget loaders use the shared shimmer Skeleton.","body":"More loading-state consistency: the root dashboard tiles, the task custom-fields panel,\nthe tasks-for-target widget, and the TipTap artifact-reference chip replaced their\nhand-rolled `animate-pulse` loading blocks with the shared `Skeleton` primitive (crafted\ngradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T23:08:10.577Z","updatedAt":"2026-06-06T23:08:10.577Z"},{"id":"7caf13d1-ef9e-4ec5-a085-08cbfa9770f7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch25","type":"changed","scope":"web","summary":"Project overview, public invoice, and HRM document-sign pages use the shared Skeleton.","body":"More loading-state consistency: the project overview page, the public invoice-view page,\nand the HRM public document-sign page replaced their hand-rolled `animate-pulse` loading\nblocks with the shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:47.246Z","updatedAt":"2026-06-07T03:14:47.246Z"},{"id":"ec58657f-0734-45a4-8605-f9c686464ca9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch26","type":"changed","scope":"web","summary":"Project cycle detail page uses the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the project cycle detail page replaced its hand-rolled\n`animate-pulse` loading blocks with the shared `Skeleton` primitive (crafted gradient\nshimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:47.584Z","updatedAt":"2026-06-07T03:14:47.584Z"},{"id":"db703348-200a-4ab8-b154-178307074a83","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch27","type":"changed","scope":"web","summary":"Projects analytics page uses the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the projects analytics page (velocity chart, status mix,\nworkload, and the contributors table) replaced its hand-rolled `animate-pulse` loading\nblocks with the shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:47.694Z","updatedAt":"2026-06-07T03:14:47.694Z"},{"id":"adb3898e-6f13-420c-9c86-e87e09873f03","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch28","type":"changed","scope":"web","summary":"Project data panel uses the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the project data panel (entity lists, PDF/preview frames,\nand the side lists) replaced its hand-rolled `animate-pulse` loading blocks with the shared\n`Skeleton` primitive (crafted gradient shimmer, dark-mode-correct). Width-driven inline\nstyles on the placeholder bars are preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:47.824Z","updatedAt":"2026-06-07T03:14:47.824Z"},{"id":"524b3674-7ed7-40ae-a9d6-4b221fe9667f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch29","type":"changed","scope":"web","summary":"Recruitment application detail page uses the shared shimmer Skeleton for loading states.","body":"More loading-state consistency: the recruitment application detail page (header, tab bar,\nbody, and side panels) replaced its hand-rolled `animate-pulse` loading blocks with the\nshared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct). One bare\n`animate-pulse` CardBody with no neutral background token was left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:47.861Z","updatedAt":"2026-06-07T03:14:47.861Z"},{"id":"ed8396e4-79bb-4b41-b550-2dcbbb5c016b","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch30","type":"changed","scope":"web","summary":"Client detail page uses the shared shimmer Skeleton for loading states.","body":"Final loading-state consistency pass: the client detail page (the largest single\ncollection of hand-rolled loaders in the app) replaced all its `animate-pulse` loading\nblocks with the shared `Skeleton` primitive (crafted gradient shimmer, dark-mode-correct).\nThis completes the web-app loading-state consistency campaign — every convertible\nhand-rolled loader now uses the shared primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:48.188Z","updatedAt":"2026-06-07T03:14:48.188Z"},{"id":"acd257eb-c07a-4b6f-a046-f11665a8683e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"skeleton-loading-states-batch31","type":"changed","scope":"web","summary":"Task detail sheet uses the shared shimmer Skeleton for loading states.","body":"Loading-state consistency: the task detail sheet replaced its hand-rolled `animate-pulse`\nloading blocks (header, comments, subtasks, side lists) with the shared `Skeleton`\nprimitive (crafted gradient shimmer, dark-mode-correct). Two `animate-pulse` indicators\nthat signal live run state (accent-success/danger dots) are intentionally left, since they\nconvey activity rather than a loading placeholder.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:14:48.190Z","updatedAt":"2026-06-07T03:14:48.190Z"},{"id":"189925e9-f33f-4d05-9683-b4cb73d8b994","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"dark-mode-elevation-foundation","type":"changed","scope":"web","summary":"Dark mode now uses a legible, framed elevation ladder instead of one flat near-black.","body":"Dark mode was effectively two near-identical near-blacks: the app shell, top bar,\nand module sub-nav all rendered on the darkest canvas, cards were barely lifted, and\nthe top elevation step was never used. It now uses a deliberate \"soft charcoal\"\nelevation ladder (cool slate, hue 248) so the UI reads as nested containers:\n\n- **Desk / canvas** (`--bg-app`, ~14%) — the page scroll area + shell base.\n- **Chrome frame** (`--bg-chrome`, ~16.5%, new token) — the left rail, top bar, and\n  module sub-nav, so they frame a darker content desk. Scoped to dark; light chrome\n  is unchanged.\n- **Surface** (`--bg-default`, ~19%) — cards and the page main container.\n- **Raised / Inner** (`--bg-subtle` ~22%, `--bg-emphasis` ~24.5%) — nested sub-/inner\n  containers (`--bg-emphasis` is now defined in light too, and is no longer unused).\n- **Overlay** (`--bg-popover`, ~22.5% + blur + shadow) — menus, modals, sheets.\n- Inputs get a recessed `--bg-input` well so they read as carved-in.\n\nLight mode is intentionally untouched. The default-theme platform setting and the\nper-user light/dark/system toggle are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:52:36.859Z","updatedAt":"2026-06-07T03:52:36.859Z"},{"id":"b3fc4f9f-2126-45a2-95bf-704c786bcd8c","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"dark-mode-input-wells","type":"changed","scope":"web","summary":"Inputs, selects, and textareas read as recessed wells in dark mode.","body":"The Input, Select, and Textarea primitives now use the `--bg-input` token instead of\nthe card surface, so in dark mode form fields sit in a recessed well a step below the\ncard (carved-in rather than floating). Light mode is unchanged (`--bg-input` resolves\nto the default white surface there). Part of the dark-mode elevation pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:52:37.551Z","updatedAt":"2026-06-07T03:52:37.551Z"},{"id":"f68645bb-3362-4764-9a7f-c4ed44146fee","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-directory-row-peek","type":"changed","scope":"hrm","summary":"Clicking an employee in the HRM directory opens a quick-look side panel instead of a full page navigation.","body":"Clicking a row in the HRM **Directory** now opens a right-side **quick-look\npanel** — employee number, contact details, department, position, manager,\nemployment type, status, location, and timezone at a glance — instead of\nnavigating away. An **Open full record →** link in the panel footer promotes to\nthe full employee page when you need it. Matches the CRM list peek pattern.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:52:37.805Z","updatedAt":"2026-06-07T03:52:37.805Z"},{"id":"7be9f5a1-86b5-4a16-a9ab-963933ea8001","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"hrm-lists-type-icons","type":"changed","scope":"hrm","summary":"HRM directory, leave, and time tables now show field-type icons in their column headers.","body":"The HRM **Directory**, **Leave**, and **Time** tables adopt the shared\nfield-type header icons — each column header shows a small text / number / date /\nselect / relation cue, matching the CRM lists and making the grids easier to\nscan at a glance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T03:52:37.806Z","updatedAt":"2026-06-07T03:52:37.806Z"},{"id":"a7c1f1a2-fa8d-44b0-afdd-763256b7f2b6","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"auth-input-focus-autofill","type":"fixed","scope":"web","summary":"Auth input fields no longer show a double/clipped focus ring or jarring autofill boxes.","body":"The auth input fields (signup wizard, login, password reset, step-up, OTP) behaved\nweirdly on focus and autofill:\n\n- **Double / clipped focus ring** — `PolishedField` and the segmented `OtpInput` draw\n  their own focus ring on the field wrapper, but their inner `<input>` was missing the\n  `data-slot-input` marker, so the global field focus-ring also applied to the input and\n  nested a second ring inside the wrapper — which the wrapper's `overflow-hidden` then\n  clipped into an odd inner line. Both inner inputs now carry `data-slot-input`, leaving a\n  single clean ring.\n- **Autofill** — there was no `-webkit-autofill` handling anywhere, so the browser painted\n  its own pale-blue/yellow box and text colour inside fields (especially jarring in dark\n  mode). A global rule now repaints autofilled fields to the `--bg-input` well + the default\n  foreground, so autofilled and typed fields look identical.\n- The `PolishedField` surface now uses the recessed `--bg-input` well (consistent with the\n  other field primitives and matching the autofill repaint). Light mode is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:33.706Z","updatedAt":"2026-06-07T04:26:33.706Z"},{"id":"aa8b198e-28f6-4a24-b4c3-285218a99bdb","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"engagements-row-peek","type":"changed","scope":"clients","summary":"Clicking an engagement opens a quick-look side panel instead of navigating to its full page.","body":"Clicking a row in the **Engagements** list now opens a right-side **quick-look\npanel** — client, type, billing model, status, owner, contract value, monthly\nrun rate, and key dates — instead of navigating away. An **Open full record →**\nlink promotes to the full engagement page. Matches the CRM / HRM list peek\npattern.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:34.404Z","updatedAt":"2026-06-07T04:26:34.404Z"},{"id":"e43471e4-369f-467a-82d0-234ef33e3fc1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-me-payslip-peek","type":"changed","scope":"payroll","summary":"Clicking one of your payslips opens a quick-look side panel with gross, net, payday, and status.","body":"In **Payroll → My pay**, clicking a payslip row now opens a right-side\n**quick-look panel** — pay group, payday, gross, net, status, and issue date —\nwith an **Open full record →** link to the full payslip. The payslip-number\ndeep link still works independently. Matches the peek pattern across the app.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:34.694Z","updatedAt":"2026-06-07T04:26:34.694Z"},{"id":"8379ea28-92f8-4d10-aa3f-17685d88fa30","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"dark-mode-defect-fixes","type":"fixed","scope":"web","summary":"Fix real dark-mode color/elevation defects surfaced by a full-app audit.","body":"A multi-agent dark-mode audit surfaced (and adversarial triage confirmed) a handful of\ngenuine defects, now fixed:\n\n- **Changelog admin** \"breaking\" + \"notify-on-publish\" badges used raw Tailwind palette\n  (`rose-*` / `violet-*`) that doesn't adapt to dark → now status/AI tokens.\n- **Security settings** session-type cards referenced **undefined** `--brand-primary` /\n  `--brand-subtle` vars (active state rendered colorless) → now `--accent` / `--accent-bg`.\n- **Invoice + organization** accent buttons used `text-white` on `--accent` (poor contrast\n  on the amber accent) → now `--fg-on-accent`.\n- **Clients** bulk-select toolbar + selected rows used `--accent-50` (a faint tint in dark)\n  with `--accent-800` text (illegible on dark) → now `--bg-selected` + `--accent`.\n- **Platform email dashboard** rate panels were flat against their card → `--bg-subtle`.\n- **Kanban** column drag-hover shadow + a **project data** popover used hardcoded black\n  shadows → now the dark-aware shadow tokens, and the popover uses the overlay surface.\n\nIntentional cases (saturated status badges with white text, white switch knobs, `var(--x,\n#hex)` fallbacks on defined tokens) were verified as correct and left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:34.405Z","updatedAt":"2026-06-07T04:26:34.405Z"},{"id":"ed7526f6-cc72-4ee7-b3a4-7437e61ef871","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"renewals-row-peek","type":"changed","scope":"clients","summary":"Clicking a renewal opens a quick-look side panel with the engagement's key figures and dates.","body":"Rows in the **Renewals** list are now clickable — opening a right-side\n**quick-look panel** with the engagement's client, type, status, renewal date,\nmonthly run rate, and contract value, plus an **Open full record →** link to the\nfull engagement page. Matches the peek pattern across the other lists.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:34.663Z","updatedAt":"2026-06-07T04:26:34.663Z"},{"id":"b56b22fc-542a-4f8f-88d1-d67b7da552e7","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"subscriptions-row-peek","type":"changed","scope":"sales","summary":"Clicking a subscription opens a quick-look side panel with its billing details and dates.","body":"Rows in the **Subscriptions** list are now clickable, opening a right-side\n**quick-look panel** — client, status, interval, per-cycle amount, start / next-\ncharge / trial / end dates — with an **Open full record →** link to the full\nsubscription page. Previously the list had no row click-through. Matches the peek\npattern across the other lists.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:34.692Z","updatedAt":"2026-06-07T04:26:34.692Z"},{"id":"6eee4d5b-8eff-43dd-a735-c29605181407","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"collections-row-peek","type":"changed","scope":"sales","summary":"Clicking a debtor in Collections opens a quick-look panel with their balance and oldest overdue age.","body":"In **Sales → Collections**, clicking a debtor row now opens a right-side\n**quick-look panel** — customer, total due, open-invoice count, and oldest\noverdue age — with an **Open full record →** link to the client page. The\ncustomer-name link keeps its direct navigation, and the panel clears when you\nswitch the currency view.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:29.630Z","updatedAt":"2026-06-07T07:34:29.630Z"},{"id":"7475be59-0a4e-44ac-9ea5-b83758d05e69","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"contact-deals-panel","type":"added","scope":"crm","summary":"A contact's record now shows a Deals panel listing the deals they're the primary contact on.","body":"The CRM **contact record page** gains a **Deals** panel in its related-records\ncolumn — listing the deals where the contact is the primary contact, each\nclickable through to the deal, with its amount (or stage). Closes the loop with\nthe deal record's existing Contacts panel.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:29.643Z","updatedAt":"2026-06-07T07:34:29.643Z"},{"id":"68be6125-930f-4611-8cb9-43fad01b740f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-select-primitive-batch1","type":"changed","scope":"web","summary":"SaaS console dropdowns use the shared Select primitive.","body":"Form-polish plan, Phase 2 (batch 1): raw `<select>` dropdowns across the SaaS\nconsole (auth providers, changelog, feature toggles, operator alerts, plans,\nmaintenance, status, webhooks) and the notifications audit page now use the\nshared `Select` primitive — consistent chevron, hover, and focus-ring treatment\nwith the rest of the form system.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:29.988Z","updatedAt":"2026-06-07T07:34:29.988Z"},{"id":"d54de6fe-657f-4d18-8864-9ce335ee4356","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-select-primitive-batch3","type":"changed","scope":"web","summary":"Careers, invoice, form-builder, and CMS-editor dropdowns use the shared Select primitive.","body":"Form-polish plan, Phase 2 (batch 3): raw `<select>` dropdowns across the public\ncareers apply + list pages, the public invoice view, the help \"what's new\" page,\nthe form builder (field edit + validations), the job-create sheet, the sales\nengagement-link block, and the website CMS sections editor now use the shared\n`Select` primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.273Z","updatedAt":"2026-06-07T07:34:30.273Z"},{"id":"81c824b7-8f66-4eb6-be55-a059263c7fea","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-select-primitive-batch2","type":"changed","scope":"web","summary":"HRM, recruitment, CRM, and settings dropdowns use the shared Select primitive.","body":"Form-polish plan, Phase 2 (batch 2): raw `<select>` dropdowns across HRM\n(directory, leave), recruitment (recruit wizard, applications, compliance),\nthe CRM contacts page, the help service page, and HRM settings (comp review,\ndocument templates, email templates) now use the shared `Select` primitive.\nThe recruitment applications active-filter control keeps its bespoke\naccent-wash styling, which the primitive can't reproduce on the control itself.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.275Z","updatedAt":"2026-06-07T07:34:30.275Z"},{"id":"30e83420-760d-4cf9-86d9-6351f03eeb22","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-select-primitive-batch4","type":"changed","scope":"web","summary":"Project widget dropdowns use the shared Select primitive.","body":"Form-polish plan, Phase 2 (batch 4): raw `<select>` dropdowns in the project\nwidgets (AI plan-from-brief panel, AI status-update dialog, paste-to-tasks\npanel, project members panel) now use the shared `Select` primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.533Z","updatedAt":"2026-06-07T07:34:30.533Z"},{"id":"3c609175-6bf1-42b6-80e7-4c02f972d6a9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-hrm-settings","type":"changed","scope":"web","summary":"HRM settings description field uses the shared Textarea primitive.","body":"Form-polish plan, Phase 1 (final convertible route): the HRM settings holiday/policy\ndescription field now uses the shared `Textarea` primitive. Remaining raw `<textarea>`\nelements are intentional code/JSON/rich-text editors and chat composers, which keep\ntheir bespoke styling.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.927Z","updatedAt":"2026-06-07T07:34:30.927Z"},{"id":"f80704be-8c87-4053-8a7b-1160969eff9f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"payroll-lists-row-peek","type":"changed","scope":"payroll","summary":"Payslips, payroll runs, and pay-group members open a quick-look side panel on row click.","body":"The payroll admin lists adopt the row-peek pattern. Clicking a row opens a\nright-side **quick-look panel**:\n\n- **Payslips** — employee, pay group, gross, net, payday, status → full payslip.\n- **Payroll runs** — status, pay group, kind, period, payday, gross, net,\n  headcount → full run.\n- **Pay-group members** — employee number, work email, effective window, and\n  membership status → the employee's directory record.\n\nEach panel's **Open full record →** link promotes to the full page; existing\ndeep-link cells keep working independently.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.971Z","updatedAt":"2026-06-07T07:34:30.971Z"},{"id":"a2adec52-c149-4fcf-8467-4d1c0dcbd7a1","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-batch1","type":"changed","scope":"web","summary":"Sales + settings forms use the shared Textarea primitive instead of raw textareas.","body":"Form-polish plan, Phase 1 (batch 1): raw `<textarea>` elements across the sales module\n(approvals, invoices, credit-notes, quotations, products) and settings (support\ncanned-replies / email-templates / widgets / status / services / ai-agents / changelog,\nemail logs + templates) now use the shared `Textarea` primitive — consistent border,\nfocus ring, the recessed `--bg-input` well, and dark-mode adaptation (which raw textareas\nwere missing). Deliberate HTML/markup code-editor textareas were left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.558Z","updatedAt":"2026-06-07T07:34:30.558Z"},{"id":"46e24b98-9726-4dc2-aa9b-7ca199f83808","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-batch2","type":"changed","scope":"web","summary":"More component forms use the shared Textarea primitive instead of raw textareas.","body":"Form-polish plan, Phase 1 (batch 2): raw `<textarea>` elements in shared component forms\n(clock widget, CRM contact email composer, form-builder structured editor, interview\nschedule sheet, offer create sheet, onboarding tasks panel, project template author form,\nreject-application modal, AI plan-from-brief panel) now use the shared `Textarea`\nprimitive. The deliberate JSON body editor in the benefits editor was left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.668Z","updatedAt":"2026-06-07T07:34:30.668Z"},{"id":"f2acf760-cc00-4e09-8ae6-af6010b3c484","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-batch3","type":"changed","scope":"web","summary":"Remaining component-form textareas use the shared Textarea primitive.","body":"Form-polish plan, Phase 1 (batch 3): raw `<textarea>` elements in the remaining shared\ncomponent forms (engagement edit sheet, job create sheet, manual application sheet, support\nportal inbox, AI status-update dialog, custom-fields admin panel, milestone editor,\npaste-to-tasks panel, project client-messages panel, recurring-task editor) now use the\nshared `Textarea` primitive — completing the component-level textarea sweep.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.725Z","updatedAt":"2026-06-07T07:34:30.725Z"},{"id":"34f229b9-027b-4b58-873f-a77acbaa7b1d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-batch4","type":"changed","scope":"web","summary":"HRM + recruitment form textareas use the shared Textarea primitive.","body":"Form-polish plan, Phase 1 (batch 4): raw `<textarea>` elements across HRM (leave,\nperformance, time), recruitment (application detail, library, settings), and the HRM\nsettings pages (benefits, comp-review, document-templates, equity) now use the shared\n`Textarea` primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.802Z","updatedAt":"2026-06-07T07:34:30.802Z"},{"id":"ad2ad1c6-62ec-4c0f-9fe8-76e74a21ca86","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-batch6","type":"changed","scope":"web","summary":"Recruitment offer, SaaS console, and support agent textareas use the shared Textarea primitive.","body":"Form-polish plan, Phase 1 (batch 6, final route sweep): raw `<textarea>` elements\nacross the public recruitment offer page, the SaaS console (auth providers,\nchangelog), HRM settings (retention, translations), and the support agent surfaces\n(ticket detail, live console, inbox) now use the shared `Textarea` primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.925Z","updatedAt":"2026-06-07T07:34:30.925Z"},{"id":"fd6ae2e1-2eb5-44b9-a5c9-cae437157333","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"pipeline-manager-wip-limit","type":"added","scope":"crm","summary":"Set a per-stage WIP limit when editing a pipeline stage; it drives the deals-board over-limit cue.","body":"The **Manage pipelines** stage editor gains a **WIP limit** field (1–9999, blank\n= none) alongside Win % and Stall days. Setting it makes the deals board show\n\"n / limit\" on that stage and turn amber when it's overloaded — completing the\nwork-in-progress-limit feature end to end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:31.050Z","updatedAt":"2026-06-07T07:34:31.050Z"},{"id":"f5f5c7a0-b23e-4fe6-958a-d29302703bff","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-textarea-primitive-batch5","type":"changed","scope":"web","summary":"Client-portal, careers, help, and public-invoice textareas use the shared Textarea primitive.","body":"Form-polish plan, Phase 1 (batch 5): raw `<textarea>` elements across the client\nportal (project detail, support list + thread), careers (apply, withdraw), the\nclient record page, the help centre (article, contact, service), and the public\ninvoice view now use the shared `Textarea` primitive.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.835Z","updatedAt":"2026-06-07T07:34:30.835Z"},{"id":"caef8421-a5d2-4a28-b60a-87e3888bca33","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"kanban-wip-limit-cue","type":"added","scope":"crm","summary":"Deals-board columns show a WIP-limit indicator (n / limit) that turns amber when a stage is over its limit.","body":"When a pipeline stage has a work-in-progress limit set, the deals board column\nheader now reads **\"n / limit\"** and the count chip turns **amber** once the\nstage exceeds its limit — an at-a-glance signal that a stage is overloaded. The\nKanban primitive gained an optional per-column `limit` for any board to use; the\ndeals board wires it from each stage's `wipLimit`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:30.969Z","updatedAt":"2026-06-07T07:34:30.969Z"},{"id":"c03971a3-69ee-45cc-8fe8-6650c968e0c5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-select-primitive-batch5","type":"changed","scope":"web","summary":"Projects module dropdowns use the shared Select primitive.","body":"Form-polish plan, Phase 2 (batch 5, final): raw `<select>` dropdowns across the\nProjects module (project data panel, analytics, cycles, projects list) now use\nthe shared `Select` primitive. This completes the Phase 2 select sweep for all\nconvertible surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:00:37.754Z","updatedAt":"2026-06-07T08:00:37.754Z"},{"id":"92ee93f2-864d-4789-840b-147ade5a64de","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"kanban-column-collapse","type":"added","scope":"crm","summary":"Collapse a deals-board column to a narrow rail; the collapsed set is remembered per board.","body":"Board columns can now be **collapsed** to a narrow vertical rail (click the\ncaret in the column header) and expanded again by clicking the rail — handy for\nfocusing on a few stages on a wide pipeline. The collapsed set is remembered per\nboard (localStorage). On by default for the deals board; the Kanban primitive\ngained an opt-in `collapsible` + `boardId` for any board to use.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:00:37.988Z","updatedAt":"2026-06-07T08:00:37.988Z"},{"id":"5d30f7bf-0c14-45d9-a1b6-4f27177d86ef","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-input-primitive","type":"changed","scope":"web","summary":"Remaining raw text/date inputs use the shared Input primitive.","body":"Form-polish plan, Phase 3: the remaining raw text/date-like `<input>` controls the\nearlier native-inputs pass missed — the help \"what's new\" search, the SaaS console\n(changelog editor, plans editor) text/search/date fields, and the world-clock widget\nlabels — now use the shared `Input` primitive. File, checkbox, radio, and range\ninputs (which belong to other primitives) and genuine chrome-owning composites (the\nsignup invite row, the bulk-action date overlay) are intentionally left as-is.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:00:37.746Z","updatedAt":"2026-06-07T08:00:37.746Z"},{"id":"5021e34f-7481-441a-ad2e-0c7aed73b16f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"wip-review-fixes","type":"fixed","scope":"crm","summary":"Deals-board column count/WIP cue uses the true stage total; smoother inline-edit dot; contact Deals panel shows load errors.","body":"Adversarial review of the WIP-limits / collapse / inline-edit work surfaced\nthree refinements (all applied):\n\n- **Deals board** — the column count chip and WIP-limit cue now use the true\n  per-stage open-deal total (server aggregate) instead of the rendered card\n  count, so a search filter or the 200-card page cap no longer understates them.\n- **Inline-edit dot** — the just-changed dot now fades via opacity instead of\n  mounting/unmounting, so a save never nudges the cell's layout.\n- **Contact Deals panel** — a failed load now shows \"Couldn't load deals.\"\n  instead of silently reading as \"No deals yet.\"","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:00:37.994Z","updatedAt":"2026-06-07T08:00:37.994Z"},{"id":"54672c4b-d094-4a7f-9a2c-598c3b468455","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-picker-inline-entity","type":"changed","scope":"web","summary":"Offer-template and job pickers in recruitment sheets are now searchable.","body":"Form-polish plan, Phase 4 (inline entity selectors): the offer-template picker in the\noffer-create sheet and the job picker in the manual-application sheet now use the\nsearchable `Picker` combobox. Short stable-enum dropdowns in the same sheets (member\nrole, panelist role, starting stage) deliberately stay as plain `Select` — a search box\nadds nothing for a handful of fixed options.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:42:28.383Z","updatedAt":"2026-06-07T08:42:28.383Z"},{"id":"df104151-c821-45cf-bcf2-5c241456e2b2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-picker-entity-components","type":"changed","scope":"web","summary":"HRM entity pickers (department, position, office, shift) are now searchable.","body":"Form-polish plan, Phase 4 (entity pickers): the shared HRM entity pickers —\ndepartment, position, office location, and shift — now use the searchable\n`Picker` combobox instead of a plain dropdown. Type-ahead filtering makes long\ncatalogs (up to 100–200 rows) usable, and the shift picker shows each shift's\ncolour swatch. Every consumer (job create, employee, offer, roster forms) gets\nthe upgrade automatically; the `value`/`onChange` contract is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:42:28.412Z","updatedAt":"2026-06-07T08:42:28.412Z"},{"id":"484dfbd8-d9a6-4739-a18d-886f199d8127","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-picker-inline-entity-batch2","type":"changed","scope":"web","summary":"More entity-list dropdowns (members, clients, grants, pay groups…) are now searchable.","body":"Form-polish plan, Phase 4 (inline entity selectors, batch 2): entity-backed dropdowns\nacross teams (member), engagements (client), the job-create sheet (interview kit),\nthe blog meta panel (author, category, editor), copy-questions-from (source job),\nexpenses (category), HRM equity (employee, grant), and payroll runs (pay group,\nperiod) now use the searchable `Picker` combobox. Fixed-enum dropdowns in the same\nfiles (roles, types, statuses, grant types, vesting cadence, payroll kind/status)\ndeliberately stay as plain `Select`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:42:28.440Z","updatedAt":"2026-06-07T08:42:28.440Z"},{"id":"569dca7d-220d-44ec-9a10-08bba6127c3e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-set-variable-multi-condition","type":"added","scope":"crm","summary":"CRM automations gain a Set-variable step and multi-clause (AND/OR) conditions.","body":"First wave of the workflow-automation enterprise upgrade:\n\n- **Set variable** step — compute a `{{ }}` templated value (e.g. combine a full\n  name, build a label) and reuse it in later steps as `{{ <step>.<key> }}`,\n  without writing anything.\n- **Multi-clause conditions** — a condition gate can now combine several\n  comparisons with **AND** (all) or **OR** (any), not just one. Existing\n  single-comparison conditions keep working unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:42:28.446Z","updatedAt":"2026-06-07T08:42:28.446Z"},{"id":"8429ed2d-1201-469c-9d65-15730939104f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-account-support","type":"changed","scope":"web","summary":"The client-portal new-ticket form uses the unified Form stack with inline validation.","body":"Form-polish plan, Phase 5: the client-portal \"new support ticket\" form now uses the\nunified `useAppForm` + `Form`/`FormInput`/`FormTextarea`/`FormSubmit` stack with\nZod-resolved validation and inline field errors, replacing the hand-rolled\n`useState` + manual-submit version.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T13:55:30.199Z","updatedAt":"2026-06-07T13:55:30.199Z"},{"id":"8ae1edca-d4c5-44a0-8aeb-5e58d591bd5e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-picker-inline-entity-batch3","type":"changed","scope":"web","summary":"User, team, employee, and benefit-package dropdowns are now searchable.","body":"Form-polish plan, Phase 4 (inline entity selectors, batch 3): entity-backed dropdowns\nfor linking a user (IAM user-link picker), choosing a team (project cycles, projects\nlist filter), selecting an employee or department (HRM time tracking), and picking a\nbenefit package (employee detail) now use the searchable `Picker` combobox, with\navatars where available. Fixed-enum and fixed-catalog dropdowns in the same files\n(membership role, project status/sort, time-entry source/category/break-kind, email\nprovider kinds, bank-account type) deliberately stay as plain `Select`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T13:55:30.237Z","updatedAt":"2026-06-07T13:55:30.237Z"},{"id":"93061311-e870-4e28-8b6d-b43082952765","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-ai-generate-node","type":"added","scope":"crm","summary":"CRM automations gain an AI step — generate text with Claude inside a workflow and reuse it downstream.","body":"CRM automations can now include an **AI step**. Give it a prompt (with\n`{{ }}` variables from earlier steps) and it generates text with the platform\nAI — draft a personalised follow-up email, summarise a deal, classify a lead —\nexposing the result to later steps as `{{ <step>.text }}`. It runs through the\nsame routed, cost-tracked AI runtime as the rest of the product, and the step\nfails cleanly if no AI provider is configured.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T13:55:30.426Z","updatedAt":"2026-06-07T13:55:30.426Z"},{"id":"d1ae1c9d-f644-418c-a8da-96618c1d2292","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-labels","type":"changed","scope":"web","summary":"The projects label-create form uses the unified Form stack; name conflicts show inline.","body":"Form-polish plan, Phase 5: the projects \"new label\" form now uses the unified\n`useAppForm` + `Form`/`FormField` stack. The custom colour-swatch picker is wired\nthrough the field render-prop, the bespoke layout is preserved, and a duplicate-name\nconflict now surfaces as an inline field error instead of a toast.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T13:55:30.444Z","updatedAt":"2026-06-07T13:55:30.444Z"},{"id":"40fbe3b2-f9c9-4954-8b52-2745420cb37e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-run-get","type":"added","scope":"crm","summary":"New crm.workflow.run.get action returns one automation run in full — input + per-step log — for the run inspector.","body":"Adds `crm.workflow.run.get`, a read action that returns a single automation run\nin full: its status, the trigger input it started from, and the per-step log\n(each step's id, type, ok, output, and error) with start/finish timing. The\nfoundation for the upcoming run-inspector UI (the data was already captured on\neach run; this surfaces it).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T13:55:30.684Z","updatedAt":"2026-06-07T13:55:30.684Z"},{"id":"39071e66-443b-4692-a2cc-14c8075d67b5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5a-batch","type":"changed","scope":"web","summary":"Several reason/reply/create modals now use the unified Form stack with inline validation.","body":"Form-polish plan, Phase 5 (simple modals batch): the reject-application, expense-reject,\nsave-view, project client-message reply, support-ticket reply, and hire-application forms\nnow use the unified `useAppForm` + `Form` stack with Zod-resolved validation and inline\nfield errors, replacing hand-rolled `useState` + manual-submit versions. Behaviour\n(payloads, success/error handling, custom date picker, scope radios) is preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:55.884Z","updatedAt":"2026-06-07T17:58:55.884Z"},{"id":"94e9807e-6ca0-4305-a47b-394fb2de2da9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"fix-popover-menu-zindex-in-modals","type":"fixed","scope":"ui","summary":"Date pickers and dropdown menus opened inside sheets/dialogs now appear (z-index fix).","body":"Fixed the `DatePicker` calendar (and any `Popover` or dropdown `Menu`) appearing to\n\"do nothing\" when opened from inside a Sheet or Modal. The floating content portals\nto `<body>`, where z-index — not DOM order — decides stacking, and Popover/Menu sat\nat `z-[20]`, below Sheet (`z-[40]`) and Modal (`z-[50]`), so the calendar/menu opened\nbehind the surface. Popover and Menu now render at `z-[60]` (matching Picker), above\nsheets and dialogs and below tooltips/command-palette.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:55.886Z","updatedAt":"2026-06-07T17:58:55.886Z"},{"id":"e4995f9d-d18b-4a84-9cfd-99ca45eab373","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-builder-new-nodes","type":"added","scope":"crm","summary":"The automation builder can now add Set-variable and Generate-with-AI steps.","body":"The CRM automation builder gains two new step types in its action picker:\n\n- **Set a variable** — compute a `{{ }}` value and reuse it in later steps.\n- **Generate with AI** — write a prompt, pick how many tokens, and save the\n  generated text as a named variable for later steps (e.g. draft an email body,\n  then send it).\n\nEach gets its own config panel, flow-node icon, summary line, and validation —\nmaking the engine capabilities shipped this week authorable without code.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.131Z","updatedAt":"2026-06-07T17:58:56.131Z"},{"id":"489051df-6b71-42b7-95b8-61a9a4921569","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-run-inspector","type":"added","scope":"crm","summary":"Click a run in an automation's history to inspect its trigger input and per-step results.","body":"The automation **Run history** is now drillable: click any run to open a\n**run inspector** showing the trigger input it started from and a per-step log —\neach step's status (✓ / ✗), output, and error — plus total duration. Makes it\neasy to see exactly what an automation did and why a step failed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.131Z","updatedAt":"2026-06-07T17:58:56.131Z"},{"id":"c7415cd5-9847-44ab-b9c3-77d870a04623","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-templates","type":"added","scope":"crm","summary":"A prebuilt automation-template library you can instantiate into a draft workflow in one step.","body":"CRM automations gain a **starter template library** — prebuilt, ready-to-edit\nworkflows (welcome new leads, AI-personalized follow-up, hot-lead alert, deal-won\nonboarding kickoff). New actions `crm.workflow.template.list` and\n`crm.workflow.instantiate_template` let you browse the gallery and spin a\ntemplate into a new draft workflow (copying its trigger + steps) to review,\ntweak, and activate — no building from scratch.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.371Z","updatedAt":"2026-06-07T17:58:56.371Z"},{"id":"398c67c2-62d4-4b25-b6c6-90f44c710f6d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-templates-gallery","type":"added","scope":"crm","summary":"A \"Templates\" gallery in the automation builder to start a workflow from a prebuilt template.","body":"The automation builder gains a **Templates** button that opens a gallery of\nprebuilt automations (welcome new leads, AI-personalized follow-up, hot-lead\nalert, deal-won onboarding). Pick one and it instantiates into a new draft\nworkflow — opened ready to review, tweak, and activate. Surfaces the new\ntemplate library end to end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.373Z","updatedAt":"2026-06-07T17:58:56.373Z"},{"id":"faa020e0-4ebe-4d9c-a9a5-df9f611e289e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5b-batch","type":"changed","scope":"web","summary":"Support, AI, HRM, and sales create/edit dialogs now use the unified Form stack.","body":"Form-polish plan, Phase 5 (medium dialogs batch): the support-portal ticket composer,\nAI status-update generator, HRM one-on-one scheduler, payment-refund modal, public\ncontact form, and the sales tax-rate, product, and AI-budget editors now use the unified\n`useAppForm` + `Form` stack with Zod-resolved validation and inline errors. Money/percent\nconversions, dynamic refund-cap validation, cross-field budget rules, and custom controls\n(currency picker, date pickers, uppercase country) are preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.567Z","updatedAt":"2026-06-07T17:58:56.567Z"},{"id":"d93b8bfa-0f69-4db5-8e7e-7f910a68f2b5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5b-batch2","type":"changed","scope":"web","summary":"Client-portal, public-offer, currency, and payroll-adjustment forms use the unified Form stack.","body":"Form-polish plan, Phase 5 (forms batch): the client-portal project message thread, the\npublic offer accept/decline forms, the manual exchange-rate editor, and the payroll\noff-cycle adjustment form now use the unified `useAppForm` + `Form` stack with Zod\nvalidation and inline errors. Custom controls (currency picker, signature, acknowledgement\ncheckbox) and bespoke submit gating are preserved via the FormField render-prop and\nform.Subscribe; money rounding and conditional payloads are unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.567Z","updatedAt":"2026-06-07T17:58:56.567Z"},{"id":"f709c26d-e245-4688-a6db-b287d2da534d","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5b-batch3","type":"changed","scope":"web","summary":"Support and HRM settings create/edit dialogs now use the unified Form stack.","body":"Form-polish plan, Phase 5 (button-modal dialogs batch): the SLA-policy, trigger,\ncanned-reply, macro, onboarding-requirement, and HR-notice create/edit dialogs now use\nthe unified `useAppForm` + `Form` stack with Zod validation and inline errors. These were\nbutton-`onClick` modals (no `<form>` element); the dialog body is now wrapped in `<Form>`\nwith a `FormSubmit`. Row actions (toggles, deletes, seed-defaults), JSON-editor parsing,\ndate→ISO conversion, and create-vs-update payloads are all preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:56.828Z","updatedAt":"2026-06-07T17:58:56.828Z"},{"id":"1eeab72c-97a0-4a1d-ac53-477d9cecd618","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-builder-branch-safe","type":"added","scope":"crm","summary":"The automation builder safely shows branch steps as a read-only summary (and never corrupts them on save).","body":"The automation builder now recognizes **branch** steps (created via template or\nAPI). It renders each as a read-only card listing its paths and step counts —\nand round-trips the branch unchanged, so editing the other steps in a workflow\nnever corrupts its branching. A full visual branch editor is on the way.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:57.088Z","updatedAt":"2026-06-07T17:58:57.088Z"},{"id":"8b6223b5-9b56-44dc-a64b-ced5b99ae750","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-sidebar-density-on-mount","type":"fixed","scope":"chat","summary":"Chat sidebar now renders at the correct density from first paint, not after clicking into a channel.","body":"The chat sidebar mounts in the AppShell's module-sidebar slot —\nOUTSIDE `[data-helios-chat]` — so its CSS variables (`--text-chat-row`,\n`--space-chat-row`, etc.) used to fall through to inline literals at\nfirst paint, reading as visibly compact. Plus `useChatDensity()` only\nmounted in `channel-view.tsx`, so the `data-chat-density` attribute on\n`<html>` wasn't set until the user clicked into a channel — at which\npoint the sidebar visibly \"expanded\" to match.\n\nThree fixes land together:\n\n1. The chat type + space tokens are hoisted to `:root`, so the sidebar\n   (and any future surface that mounts outside the chat scope) gets\n   the same scale as the message column from first paint.\n2. Density overrides (`html[data-chat-density=\"compact\"] { … }` etc.)\n   now cascade globally instead of scoping to `[data-helios-chat]`,\n   so the sidebar tracks density changes too.\n3. `useChatDensity()` is now mounted inside `ChatLayout`, and the\n   initial `data-chat-density` attribute is applied SYNCHRONOUSLY at\n   module-load — not in `useEffect` — so the cascade is correct on\n   the very first frame, including on the empty chat splash.\n\nUser-visible effect: the sidebar no longer \"becomes cozy\" the moment\nyou click your first channel; it lands at the right density on the\nsplash screen and stays there.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:55.887Z","updatedAt":"2026-06-08T16:37:55.887Z"},{"id":"42551c64-81d2-4ca0-bbe9-3258eb87c754","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5c-ai-providers","type":"changed","scope":"web","summary":"The AI-provider connect/edit form now uses the unified Form stack.","body":"Form-polish plan, Phase 5c: the AI-provider connect/edit dialog now uses the unified\n`useAppForm` + `Form` stack with Zod validation and inline field errors. Conditional fields\n(Base URL for OpenAI/OpenAI-compatible, Enabled toggle on edit) and the locked provider-kind\non edit are preserved; the new-only API-key requirement and OpenAI-compatible Base-URL rule\nare enforced via schema, and the connect-vs-update payloads (including key-rotation-on-blank)\nare unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:56.596Z","updatedAt":"2026-06-08T16:37:56.596Z"},{"id":"0e115091-f56e-4546-991b-97a997be5ac9","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"form-stack-5c-expenses","type":"changed","scope":"web","summary":"The expense create/edit form now uses the unified Form stack.","body":"Form-polish plan, Phase 5c: the employee expense create/edit dialog now uses the unified\n`useAppForm` + `Form` stack with Zod validation and inline field errors. The amount, date,\ncurrency, category, payment-method, and merchant fields keep their grid layout and custom\ncontrols (date picker, currency picker, category picker); server-side field errors are\nmapped onto the right fields, and the amount/payload conversion is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:56.615Z","updatedAt":"2026-06-08T16:37:56.615Z"},{"id":"a356eaf6-8c80-4346-b705-bb6b40537ae5","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-action-picker-groups","type":"changed","scope":"crm","summary":"The automation step-action picker is grouped into Records, Communication, Logic, and AI.","body":"The automation builder's step **action picker** now groups its options into\n**Records** (create / update), **Communication** (email / log activity),\n**Logic** (condition / wait / set variable), and **AI** (generate) — easier to\nscan as the node catalog grows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:56.881Z","updatedAt":"2026-06-08T16:37:56.881Z"},{"id":"f14631aa-eaf1-4e1c-827b-7e0b01308490","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-builder-error-handling","type":"added","scope":"crm","summary":"Configure a step's on-error behaviour and retry attempts in the automation builder.","body":"Each action step in the automation builder gains an **On error** control: choose\nwhether a failure **stops the run** or **keeps going** (continue-on-error), and\nset how many **attempts** to make (1–5). The settings round-trip safely, so\nerror handling configured via a template or the API is preserved when you edit\nthe workflow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:56.891Z","updatedAt":"2026-06-08T16:37:56.891Z"},{"id":"840033af-32a1-4157-9bc0-7a18d7a3f178","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-observability-ui","type":"added","scope":"crm","summary":"Re-run a workflow run from the inspector, and see run-health stats above the history.","body":"The automation run history is now an observability surface: a **stats strip**\n(success rate · total runs · failures · avg duration) sits above the run list,\nand the run inspector gains a **Re-run** button that replays a run with its\noriginal input. Wires up the new dashboard + retry actions.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:57.145Z","updatedAt":"2026-06-08T16:37:57.145Z"},{"id":"439d6201-9c48-40bd-b046-1ad19ae6716a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-error-handling","type":"added","scope":"crm","summary":"Automation steps can retry on failure and continue-on-error, so one hiccup no longer kills the whole run.","body":"Automation **resilience**: each action step (create / update / log / email) can\nnow set **retry** (up to 5 immediate attempts) and an **on-error** behaviour —\n`fail` (default, stops the run) or `continue` (records the failure, exposes\n`{{ <step>.error }}` to later steps, and keeps going). A run that finished but\nhad a continue-on-error failure is reported as **partial** rather than succeeded,\nso a single transient error no longer silently kills an automation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:57.159Z","updatedAt":"2026-06-08T16:37:57.159Z"},{"id":"b96673f8-4ef1-4ace-b2d1-d64828f0fb3e","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"workflow-retry-dashboard","type":"added","scope":"crm","summary":"Re-run a past automation run, and view a workflow's run metrics (success rate, avg duration, recent failures).","body":"Two automation observability actions:\n\n- **`crm.workflow.run.retry`** — re-runs a past run with the exact input it\n  recorded, against the workflow's current definition (a fresh run; the original\n  is untouched). Handy for retrying a transient failure.\n- **`crm.workflow.dashboard`** — a workflow's run health at a glance: total runs,\n  counts by status, success rate, average finished-run duration, and the most\n  recent failures.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:57.433Z","updatedAt":"2026-06-08T16:37:57.433Z"},{"id":"a8e859b9-41d3-4b6a-b949-b1c5718c96d2","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-sidebar-breathing-room","type":"changed","scope":"chat","summary":"Polished chat sidebar — looser top-bar padding, larger action-button hit-targets, more breathing room around the inline filter and section headers.","body":"Targeted spacing polish on the chat sidebar after the\ndensity-on-mount fix landed. The sidebar still read as \"cramped\"\nonce the token cascade was correct — turned out the chrome layout\nitself was tight:\n\n- **Top header band**: `pt-2.5 pb-1.5 px-2.5` → `pt-3 pb-2.5 px-3`.\n  The heading + action cluster now reads as a proper header band,\n  not a hairline strip.\n- **Action button hit-targets**: every `size-6` (24×24px) chrome\n  button bumped to `size-7` (28×28px). Modern keyboard / touch\n  standards expect ≥28px; the 24px squares felt fiddly.\n- **Action-button cluster gap**: `gap-0.5` (2px) → `gap-1` (4px).\n- **Inline filter wrapper**: `px-2.5 pb-1.5` → `px-3 pt-2 pb-2`. The\n  search bar now has a clear vertical breathing zone between the\n  header band and the channel list.\n- **Channel-list scroll container**: `px-1.5 pb-3` → `px-2 pb-3 pt-1`.\n  Rows no longer crowd the sidebar's left edge.\n- **Section header bottom padding**: `pb-1.5` → `pb-2`. \"Channels\"\n  / \"DMs\" labels no longer jam against the first row underneath.\n\nNo behaviour changes — purely visual rhythm.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:47:05.892Z","updatedAt":"2026-06-13T08:47:05.892Z"},{"id":"c5ffa3cb-39c0-4106-9145-71dd3440430a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-inbox-list-rhythm","type":"changed","scope":"chat","summary":"Polished chat catch-up inbox padding + bumped channel-list row gaps.","body":"Continued the chat-polish pass (after `e0560387` density-on-mount and\n`d193b5cc` sidebar chrome) on two more surfaces:\n\n- **`/chat/` catch-up inbox** body padding bumped across every\n  breakpoint: `px-2 py-3 sm:px-6 sm:py-4 lg:px-8` →\n  `px-3 py-4 sm:px-7 sm:py-6 lg:px-10`. The mention/DM/channel\n  sections now have a generous gutter from the viewport edge instead\n  of crowding it.\n- Inbox section header margin: `mb-2` → `mb-3`. Section title and\n  count chip no longer jam against the first row beneath them.\n- **Sidebar channel-list row gaps**: every `flex flex-col gap-[1px]`\n  channel nav bumped to `gap-[2px]`. Subtle but enough that\n  adjacent rows read as distinct items rather than a continuous strip.\n  Five instances updated (starred channels, by-category, ungrouped\n  general, DMs, sub-nested rows).\n\nNo behaviour changes — purely visual rhythm.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:58:24.958Z","updatedAt":"2026-06-13T08:58:24.958Z"},{"id":"2cb30c9c-d256-4c9e-a084-f51cc2766093","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-spaces-phase-1b","type":"added","scope":"chat","summary":"Chat Spaces Phase 1.B — action surface (list / get / create / add_member / remove_member) + chat:space:* permissions.","body":"Phase 1.B builds on `f7a3d5c9`'s schema with the minimum-viable\naction surface so operators can list, get, create, and manage\nmembers of chat spaces via the action layer.\n\nActions shipped:\n\n- **`chat.space.list`** — every space the actor is a member of, plus\n  every `open` (and `private`-labelled) space they could browse to\n  join. `secret` spaces hidden from non-members. Hydrates\n  member-count + channel-count + the actor's role per space. Sorts\n  default first → joined alpha → browseable alpha.\n- **`chat.space.get`** — single space by id or slug; returns\n  `not_found` (not `policy_denied`) for secret spaces the actor\n  can't see, to avoid existence leakage.\n- **`chat.space.create`** — creates a space + (default true) joins\n  the creator as a space admin. Slug uniqueness enforced by the\n  partial unique index — concurrent dupes return `validation_failed`\n  cleanly. Never sets `is_default` (that's a separate dangerous\n  flow).\n- **`chat.space.add_member`** — adds a user with a role (member /\n  admin / guest). Idempotent. Requires `chat:space:manage_members`\n  AND space-admin role (or `chat:admin`).\n- **`chat.space.remove_member`** — symmetric, with two guards:\n  refuses removing the **last admin** (would orphan the space) and\n  refuses removing **anyone from the default space** (every org\n  member belongs there by design).\n\nPermissions shipped:\n\n- `chat:space:read` — granted by default to every chat user (in\n  `SELF_CHAT` blueprint).\n- `chat:space:create` / `chat:space:update` /\n  `chat:space:manage_members` / `chat:space:archive` /\n  `chat:space:mention_everyone` — added to `WRITE_CHAT` (admin\n  blueprint).\n- `chat:space:set_default` / `chat:space:delete` — admin-only,\n  added next to the existing `chat:admin` grant.\n\nTests: 4 new cases (create happy path, policy denial, duplicate\nslug, malformed slug at validation). 143 chat tests pass total.\n\nNext phases: 1.C — UI switcher + space settings panel; 1.D — DM\nscoping decision + mention rescoping; 1.E — search default-scope;\n1.F — drop nullable on `space_id` columns.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:21.237Z","updatedAt":"2026-06-13T09:22:21.237Z"},{"id":"b70cf132-22c0-4f7b-bcaa-9bb9d3f83e55","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-spaces-phase-1a","type":"added","scope":"chat","summary":"Chat Spaces Phase 1.A — schema + default-space backfill for production chat data.","body":"Phase 1.A foundation for chat Spaces — a tier above channels (Slack\nworkspaces / Discord servers / Teams teams) so users can switch and\norganise their chats across multiple project / team / customer\ncontexts. Schema-only step; action surface + UI ship in 1.B / 1.C\nafter the spec workflow synthesises.\n\nWhat lands:\n\n- **`chat_spaces`** table — `(id, org_id, slug, name, description,\n  icon, accent_color, visibility, is_default, settings, created_*,\n  updated_*, deleted_at, created_by, updated_by)`. Partial unique\n  indexes on `(org_id, slug) where deleted_at is null` and\n  `(org_id) where is_default = true` (enforces \"exactly one default\n  per org\"). Visibility is `'open' | 'private' | 'secret'`.\n- **`chat_space_members`** table — `(space_id, user_id, role,\n  joined_at, last_visited_at)` with PK `(space_id, user_id)`. Role is\n  `'member' | 'admin' | 'guest'`. `last_visited_at` enables the\n  switcher's sticky-last-space behaviour.\n- **`chat_channels.space_id`** — NULLABLE through the expand window.\n  ON DELETE RESTRICT so a space can't be hard-removed while channels\n  reference it. NOT NULL constraint lands in Phase 1.F.\n- **`chat_channel_categories.space_id`** — NULLABLE, same expand\n  pattern.\n- **Backfill DO block** — every existing org with chat data gets one\n  \"default\" space (slug=`default`, name=`Default`, `is_default=true`,\n  visibility=`open`). Every existing channel + category in that org is\n  assigned to it. Every distinct channel-member becomes a\n  space-member; users who held channel-admin on any channel are\n  promoted to space-admin, everyone else lands as `member`.\n\nProduction safety:\n\n- Migration is idempotent (NOT EXISTS guards on the per-org backfill)\n  so a re-applied migration won't double-create.\n- Every existing channel keeps working — nothing reads `space_id` yet.\n  The expand window is exactly: schema landed, data backfilled, reads\n  not switched over.\n- No action surface, no UI, no permission keys, no route changes in\n  this commit. Those land in 1.B / 1.C / 1.D after the spec.\n\nSpec doc lands when the in-flight research workflow synthesises its\nfive dimensions (codebase audit + industry research + migration\nstrategy + IAM + UX/IA). Phase 1.B onwards will reference it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:21.246Z","updatedAt":"2026-06-13T09:22:21.246Z"},{"id":"d915bb41-07f8-4dd8-9005-c099820921ac","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-spaces-phase-1c-switcher","type":"added","scope":"chat","summary":"Chat sidebar gains a Space switcher dropdown — visible affordance for Phase 1.C while URL routing follows up.","body":"First UI piece of Spaces Phase 1.C. The static \"Channels\" h2 at the top\nof the chat sidebar is replaced by a `<SpaceMenu>` dropdown trigger\nthat:\n\n- Reads spaces via the new `chat.space.list` action.\n- Resolves the active space (passed-in id; falls back to the org's\n  default; then to the first joined space).\n- Shows trigger as `[icon] {SpaceName} ▾` with the chat-module\n  accent.\n- Pops a 288px menu listing the user's spaces first (\"Your spaces\"),\n  then any browseable open / private spaces (\"Browse to join\"), with\n  member-count + channel-count metadata on each row.\n- Marks the default space with a `PushPin` glyph + tooltip.\n- For >5 spaces, surfaces an inline search filter (name + slug).\n- Keyboard-accessible (Esc to close; click-outside dismiss; ARIA roles\n  set).\n- Optional `onCreate` slot for the upcoming \"Create a new space\"\n  modal.\n\nWired into the sidebar header without changing the rest of the row's\nbehaviour (rt-status dot still anchors next to the switcher, action\nbutton cluster on the right untouched).\n\n`onSelect` is a no-op for now — the route shape change to\n`/chat/$spaceSlug/$channelId` and the redirect stub for the legacy\n`/chat/$channelId` URLs land in the next commit. This commit puts the\naffordance in the chrome so operators see the new tier even before\nrouting flips.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:21.855Z","updatedAt":"2026-06-13T09:22:21.855Z"},{"id":"cf57b48f-b9df-4b45-93eb-b330f12918e8","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-space-menu-polish","type":"changed","scope":"chat","summary":"Polished the SpaceMenu — glass-treatment popover, active-row accent strip, skeleton loading rows, open-state trigger highlight.","body":"Pass over the just-shipped SpaceMenu (`055e3622`) to bring it into the\nexisting chat-popover visual vocabulary:\n\n- **Trigger**: when the popover is open, the trigger button now keeps a\n  visible hover background + a 1-px inset module-accent ring so it\n  reads as the anchor of the open popover, not just any button. Padding\n  bumped slightly (`px-1 py-0.5` → `px-1.5 py-1`) so the trigger\n  doesn't collide with the realtime-status dot next to it.\n- **Popover surface**: now uses the same glass recipe as `SavedPopover`\n  / `ScheduledPopover` (`background: bg-default at 92%`,\n  `backdropFilter: blur(14px) saturate(150%)`, layered drop-shadow\n  matching the chat-popover family). `data-helios-chat-popover`\n  attribute set so the chat-popover global type rules cover it.\n- **Entrance**: `helios-chat-popover-in` animation class applied —\n  matches the spring-soft entrance of every other chat popover.\n- **Active row**: gets a 2-px module-accent left strip (color-blind +\n  dark-mode safe) in addition to the existing background tint. Icon\n  tile bumps from 12% module-accent tint → 20% and gains a 1-px\n  inset accent ring so it visibly anchors the row.\n- **Hover row**: gains a 2-px left-pad slide on hover (same easing as\n  the SavedPopover rows) so picking up the active state vs hover\n  state is immediate.\n- **Loading state**: 3 skeleton rows that match the final row\n  dimensions (icon tile + two-line text). Replaces the plain\n  \"Loading…\" text so the popover doesn't reflow when the data\n  resolves.\n\nNo behaviour changes — purely visual rhythm + entrance polish.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:09.542Z","updatedAt":"2026-06-13T10:33:09.542Z"},{"id":"8f1ecff9-5514-4042-a535-e4923dff478f","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-spaces-phase-1c-create-modal","type":"added","scope":"chat","summary":"Create-space modal — users can now create new chat spaces from the sidebar switcher.","body":"Phase 1.C step 2 of chat Spaces. The \"Create a new space\" footer\nbutton on the sidebar SpaceMenu now opens a centred modal that\nmatches the existing `NewChannelModal` shape so muscle memory transfers\nfor users who've created channels before.\n\nForm fields:\n\n- **Name** — required, max 80 chars. Auto-focused on open.\n- **Slug** — auto-derives from the name (live preview); user can\n  override by typing. Validates against the schema's slug regex\n  (`^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$`) with red border + helpful\n  hint on invalid shape. The URL preview reads `/chat/{slug}`.\n- **Description** — optional, max 1000 chars.\n- **Visibility** — three-card picker:\n  - **Open** — anyone in the org can browse and join (default)\n  - **Private** — visible to org, members-only to enter\n  - **Secret** — hidden from non-members entirely\n- Submit button disabled until name is non-empty AND slug passes the\n  regex; calls `chat.space.create` with `joinAsAdmin: true`.\n\nOn success: invalidates the `chat.space.list` query so the SpaceMenu\nre-fetches and the new space appears in the user's list immediately;\ntoast confirms; modal resets + closes. Navigation into the new space\nlands in step 3 once the route shape is defined.\n\nServer-side validation_failed errors (duplicate slug, etc.) surface\nas a red alert under the form fields. No double-submit possible —\nbutton shows \"Creating…\" + remains disabled until the mutation\nresolves.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:09.562Z","updatedAt":"2026-06-13T10:33:09.562Z"},{"id":"0f52b5cb-77e8-4a9b-8355-faf68c54ad28","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"chat-spaces-update-archive-set-default","type":"added","scope":"chat","summary":"Chat Spaces — added update / archive / set_default actions to round out the operator surface.","body":"Phase 1.B follow-up — three more actions complete the day-to-day operator\nkit:\n\n- **`chat.space.update`** — patch-shape rename / re-icon / change\n  description / accent / visibility. Slug rename collisions return\n  `validation_failed` cleanly via the partial unique index. Cannot\n  flip `is_default` through this surface (that's a separate dangerous\n  flow). Requires `chat:space:update` AND space-admin role\n  (or `chat:admin` umbrella).\n- **`chat.space.set_default`** — atomically swaps the org's\n  default-space flag. Clears the previous default's `is_default` in\n  the same transaction to avoid colliding with the\n  `(org_id) WHERE is_default = true` partial unique index. Refuses\n  non-open visibilities (auto-join would orphan in secret/private).\n  Returns the previous default id for audit-trail confirmation.\n  Marked `dangerous: true` so the AI runtime requires confirmation.\n- **`chat.space.archive`** — soft-delete (sets `deleted_at`). Space\n  falls out of list/get for non-admins; channels inside stay\n  readable until a follow-up sweep. Refuses the default space (would\n  orphan auto-join) and non-admin actors.\n\n153 chat tests pass. The full per-org space lifecycle is now operable\nthrough actions alone — no direct DB touches needed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T10:33:10.239Z","updatedAt":"2026-06-13T10:33:10.239Z"},{"id":"70d74f76-f354-4711-b8ad-b113b6ac9e3a","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"fix-i18n-inherited-locale-writes","type":"fixed","scope":"i18n","summary":"Setting a default language and editing translations work again on inherited (platform) locales.","body":"After the platform-tier i18n rollout stopped seeding per-org locales, new\nworkspaces inherit the platform languages and have no per-org rows — which\nbroke two things on Settings → Languages and Settings → Translations:\n\n- **Set default / enable-disable a language** returned \"not enabled for this\n  org\" because the write only looked at per-org rows. It now **materializes\n  an org override** from the inherited language on demand, so choosing a\n  default or toggling a language works on a fresh workspace.\n- **Editing a translation** returned \"Key not found in this org\" for every\n  string, because the key check excluded inherited platform keys. Edits (and\n  AI / machine translate + import) now resolve inherited keys and save the\n  value as a per-org override — never mutating the shared platform copy.\n\nThe Languages list also now shows exactly one default when a workspace has\noverridden it (previously an inherited row could show as a second default).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T20:38:05.844Z","updatedAt":"2026-06-16T20:38:05.844Z"},{"id":"fbe03fee-82d7-4389-8027-2fee92fc2183","releaseId":"d96f692a-7cf1-4990-8c35-e6532607bd38","slug":"platform-locale-toggle-default","type":"added","scope":"i18n","summary":"Platform operators can enable/disable and set the default for platform-tier languages.","body":"Adds `platform.i18n.locale.toggle` and `platform.i18n.locale.set_default` so\nthe root language catalogue at /saas has the same enable/disable + set-default\ncontrols as a tenant's own Settings → Languages (operating on the platform tier\nthat every workspace inherits). Foundation for shifting the full language +\ntranslation management system to SaaS while keeping per-org overrides.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T21:06:20.142Z","updatedAt":"2026-06-16T21:06:20.142Z"}]},{"id":"a98c6c95-a694-4ba6-8732-f33e9dcab385","tag":"W2026-22","slug":"w-w2026-22","version":"0.9.0","title":"W2026-22 — 322 changes this week","summary":"Auto-published weekly digest. Covers 322 changes from 2026-05-25 → 2026-05-30 merged into main.","status":"published","publishedAt":"2026-06-04T01:28:37.569Z","periodStartsAt":null,"periodEndsAt":"2026-06-04T01:28:37.569Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["auto","weekly"],"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-25T01:24:59.746Z","entries":[{"id":"543aa3e0-43bd-426a-9f8b-88009ea52f61","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-redirects","type":"added","scope":"website","summary":"Phase 1 of the gap-closure plan — 301/302 redirects table, admin UI, and Astro 404 middleware.","body":"Before today a slug rename or an archived page on the marketing site\nreturned a hard 404 — bad for SEO and for inbound links from blogs,\ndocs, and partners.\n\nNew: a `website_redirects` table per org plus the action, admin UI,\nand middleware glue that makes it actually serve redirects.\n\nWhat ships in this phase:\n\n- **Migration `0198_0199_website_redirects.sql`** — single table:\n  `id`, `org_id`, `from_path`, `to_path`, `redirect_type` (301/302\n  enum), `enabled`, `notes`, audit timestamps, soft-delete. Partial\n  unique index ensures only one ENABLED + NOT-DELETED row per\n  `(org_id, from_path)` can coexist — disabled rows kept for audit\n  + easy toggle.\n\n- **5 actions** under `modules/website/src/actions/redirect.ts`:\n  - `website.redirect.create` — normalises `from_path` on write\n    (lowercase, leading slash, no trailing slash, no query) and\n    surfaces the partial-unique conflict as a typed `conflict` error.\n    Rejects self-redirects (from === to) and `javascript:` scheme\n    in `to_path`.\n  - `website.redirect.update` — patch + re-emit. Same conflict\n    surface as create.\n  - `website.redirect.delete` — soft delete; `dangerous: true`.\n  - `website.redirect.list` — admin search + cursor pagination.\n  - `website.redirect.lookup_public` — public, no auth. Returns\n    `{ redirect: { toPath, redirectType } | null }`. Consumed by\n    the Astro middleware.\n  All admin verbs gate on `platform:website:redirect:manage`\n  (one perm for the whole CRUD surface, root-only). Lookup is public.\n\n- **Admin UI** at `/saas/website/redirects` — table view with\n  search across from/to/notes, filter by enabled state, create +\n  edit dialog, danger-confirm on delete. New \"Redirects\" button on\n  the `/saas/website` index page.\n\n- **Astro middleware** at `apps/marketing/src/middleware.ts` —\n  consults the redirects table for non-static requests via the\n  new `lookupRedirect()` helper in `cms-runtime.ts`, which goes\n  through the existing 4-layer SWR KV cache (60 s fresh / 1 h\n  stale; misses are negative-result cached). Skips static asset\n  prefixes (`/api/`, `/_astro/`, `/og/`, etc.), well-known files\n  (`robots.txt`, `sitemap.xml`, etc.), and anything with a file\n  extension. Preserves query strings on redirect.\n\n- **3 new domain events** — `website.redirect.{created,updated,deleted}` —\n  for downstream cache-invalidation hooks. No subscribers yet; the\n  KV cache's stale-while-revalidate window absorbs the gap.\n\n- **Permission key** — `platform:website:redirect:manage` added to\n  `packages/auth/src/roles.ts` with a one-line description. NOT in\n  `STANDARD_ROLE_BLUEPRINTS` — root carries it via the all-perms\n  catalog; explicit grant for everyone else.\n\n- **12 new tests** in `modules/website/src/actions/redirect.test.ts`\n  (65 total website module tests pass). Cover happy paths for each\n  verb, policy denial, partial-unique conflict, normalisation,\n  self-redirect guard, and the lookup-returns-null path.\n\nPhase 2 (scheduled publishing) is queued next per\n`docs/plans/WEBSITE_GAP_CLOSURE_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-redirects.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d9cd0d6e-486e-4e8e-b7fc-5fabd5455871","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-scheduled-publishing","type":"added","scope":"website","summary":"Phase 2 of the gap-closure plan — schedule a CMS page to auto-publish at a future timestamp via a 60s worker cron.","body":"Before today, publishing a CMS page was always immediate — operators\nhad to be online at exactly the right moment to ship a launch page.\nPhase 2 of `WEBSITE_GAP_CLOSURE_PLAN.md` closes that gap:\n\n- **Migration `0199_0200_website_publish_at.sql`** — adds nullable\n  `publish_at timestamptz` to `website_pages` plus a partial index\n  on `(publish_at)` filtered to `publish_at IS NOT NULL AND status =\n  'draft' AND deleted_at IS NULL` so the sweep stays cheap as the\n  table grows. No backfill required.\n\n- **2 new actions** on `modules/website/src/actions/page.ts`:\n  - `website.page.schedule_publish(id, publishAt)` — marks a draft\n    row for auto-publish. Rejects past timestamps\n    (`validation_failed`), conflicts on already-published or\n    archived rows, gated by the existing\n    `platform:website:page:publish` perm.\n  - `website.page.unschedule_publish(id)` — clears `publish_at`.\n    Idempotent (no-op when not scheduled).\n  - Existing `website.page.publish` now clears `publish_at`\n    explicitly so a row picked up by the cron never re-fires.\n\n- **Worker cron** at `apps/worker/src/website-scheduled-publish-cron.ts`\n  ticks every 60s. Calls into the new\n  `runScheduledPublishSweep({ db })` exported from\n  `@helios/website/jobs`. Per-row try/catch — one bad row never\n  poisons the batch; failures keep their `publish_at` for the next\n  tick. Logger captures `{scanned, published, failed}` per tick.\n\n- **Admin UI changes**:\n  - Page editor (`/saas/website/$id`) — \"Publish now\" stays as-is;\n    new \"Schedule…\" / \"Reschedule…\" button alongside. Opens a\n    datetime-local picker dialog (min = now, prefilled to \"now + 1h\"\n    rounded to 5-min). A warning-tone banner surfaces above the\n    editor card when a draft is scheduled, with a Cancel button\n    that calls `unschedule_publish`.\n  - Page list (`/saas/website`) — draft rows with `publish_at` set\n    show a \"⏰ scheduled\" pill alongside the status badge; the\n    tooltip carries the auto-publish timestamp.\n\n- **13 new tests** — 7 in `page.test.ts` (schedule + unschedule\n  happy + denial + validation + conflict + not_found) + 4 in\n  `jobs/scheduled-publish.test.ts` (no-op when empty, per-row\n  success counting, failure isolation, missing-action no-op). All\n  78 website module tests pass.\n\nThe sweep is idempotent by construction: the publish action itself\nflips the status to `published` and clears `publish_at`, so the\nsweep's `(status='draft' AND publish_at <= now())` filter never\nmatches the same row twice. Safe under worker restart, double-tick,\nor concurrent runs.\n\nPhase 3 (i18n — `language` column + per-language rows + admin\ntranslate affordance, ~2 days) is queued next per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-scheduled-publishing.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"aedbe0b4-1290-4794-8777-657ccddd3945","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-slug-collision-guard","type":"added","scope":"website","summary":"Phase 9 of the gap-closure plan — soft cross-kind slug-collision audit (no hard block; logs + audit row).","body":"Two CMS pages of different kinds (e.g. `kind='page', slug='calendar'`\nAND `kind='integration', slug='calendar'`) used to coexist\nunnoticed — the DB unique index only catches true duplicates\nwithin `(org_id, kind, slug, language)`. Phase 9 of\n`WEBSITE_GAP_CLOSURE_PLAN.md` adds a soft audit so accidental\ncross-kind reuse surfaces in the admin's \"needs attention\" feed\ninstead of becoming silent routing ambiguity later.\n\n- **Migration `0204_0205_website_slug_collisions.sql`** — new\n  `website_slug_collisions` audit table. Columns: `id`, `org_id`,\n  `page_id` (cascade), `slug`, `kind`, `collision_kinds text[]`,\n  `detected_at`, `resolved_at`, `resolved_by`. Partial index on\n  `(org_id, detected_at) WHERE resolved_at IS NULL` so the\n  \"needs attention\" feed stays cheap as the table grows.\n\n- **`detectSlugCollisions(db, orgId, pageId, slug, kind, logger?)`**\n  helper at `modules/website/src/lib/slug-collisions.ts` runs after\n  every successful create + translate. Dedupes sibling rows by\n  kind (multiple language variants under one kind = one entry,\n  not N). Returns the audit row id when a collision is recorded;\n  `null` otherwise. NEVER throws — the write itself is fine; the\n  warning is for the audit feed.\n\n- **Wired into create + translate** — both action handlers call\n  `detectSlugCollisions` after the insert succeeds, passing\n  `ctx.logger` for the warning line. `update` doesn't need it —\n  slug is immutable after create.\n\n- **Soft warning, not a hard block.** Some kinds legitimately\n  share slugs (e.g. a blog post and a page both titled\n  `about-the-team`). The audit row + `[warn]` log entry give\n  operators visibility without taking decisions away from them.\n\n- **4 new tests** in `slug-collisions.test.ts` — happy \"no\n  collision\" path, single-kind clash, multi-language dedupe,\n  optional-logger smoke. 132/132 module tests pass.\n\n- **Doc** — `modules/website/CLAUDE.md` gained a \"Slug contract\"\n  section spelling out the rule + the future-Claude note \"do NOT\n  add a hard block.\"\n\nMigration note: renumbered from `0202_0203_*` to `0204_0205_*`\nduring commit prep because the user's parallel work added\nmigrations `0202_0203_client_documents` and\n`0203_0204_payments_connected_accounts` in the meantime. No\nbehavioural impact — just journal order.\n\nPhase 10 (media lifecycle automation — orphan-scrub Inngest cron,\n~1 day) is queued next per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-slug-collision-guard.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e97fc470-0637-4b66-81aa-4ce2c995ae97","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-credit-note-send-pdf","type":"added","scope":"sales","summary":"Credit-note detail page gains Download PDF + Send by email, matching invoices and quotes.","body":"The credit-note detail page now has the same outbound affordances invoices and quotations already had:\n\n- **Download PDF** — renders the credit-note PDF via `sales.credit_note.render_pdf` and downloads it in the browser.\n- **Send by email** — a composer (optional recipient override + note) that calls `sales.credit_note.send_email` to send the credit note with its PDF attached; recipient defaults to the client's billing email.\n\nBoth actions already existed and were reachable only programmatically; this wires them into the operator UI.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-credit-note-send-pdf.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"fbc1b762-3ff0-4ad5-aec8-47cf2aed50fa","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-cross-tab-signout-and-suggest-signin","type":"added","scope":"web","summary":"Cross-tab sign-out now bounces other open tabs to /login, and signup detects existing accounts with a \"sign in instead\" prompt.","body":"Three additions to the auth surface:\n\n- **Cross-tab sign-out** — when one tab signs out (user-menu logout),\n  every other open authenticated tab now snaps to `/login`\n  automatically instead of sitting on a stale dashboard until the\n  next fetch returns 401. `useSignOutBroadcastListener` mounted in\n  the AppShell handles it; the user-menu fires\n  `broadcastAuthChange('sign-out')`.\n- **Signup → \"Already have an account?\"** — when the email typed\n  at signup matches one or more existing workspaces (via the same\n  enumeration-safe `iam.organization.lookup_by_email` action used\n  at login), a warning-toned panel surfaces with per-row \"Sign in\"\n  buttons that deep-link to `/login?email=…&orgSlug=…`. Replaces\n  the previous bare display of memberships at signup.\n- **Sign-in transparency footer** — below the sign-in submit\n  button, a small \"Chrome · Windows · 2:34 PM\" line mirrors\n  exactly what the post-sign-in alert email + Settings → Security\n  → Login history report, so the user can cross-reference\n  unfamiliar entries at a glance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-auth-cross-tab-signout-and-suggest-signin.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5a38e329-9676-4e78-818f-0e55592aa7c6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-email-org-picker","type":"added","scope":"web","summary":"Sign-in now recognises which workspaces an email belongs to and lets multi-org users pick before submitting.","body":"When a user types their email at sign-in, the smart hint now lists\nevery active workspace that email is a member of (up to five),\neach with the org's logo / initials avatar, name, slug, and role\nbadge. Solo memberships auto-select; multi-membership users tap to\npick which workspace they want to land in. The choice threads into\n`authClient.organization.setActive` after the password verifies, so\nthe dashboard renders for the right tenant on first paint.\n\nThe new `iam.organization.lookup_by_email` action returns the same\nempty shape (`{organizations: []}`) for hit and miss to avoid\nenumeration, runs the same constant-time-ish DB join either way,\nand is rate-limited at 20 req/min/IP — generous for a single user\nmistyping their email, hostile to scripted scraping.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-auth-email-org-picker.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7dd4e20f-419d-47ca-b794-7b4d19e5540c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-otp-segmented-input","type":"changed","scope":"web","summary":"2FA codes now use a segmented six-cell input with auto-advance and auto-submit.","body":"The two-factor and step-up screens replaced their single 6-digit field\nwith the industry-standard segmented cell layout (Stripe / Linear /\nSlack). Each digit lives in its own cell with a pulsing caret on the\nactive position, the row shakes on error, and typing the sixth digit\nauto-submits — no more tapping \"Verify and continue\" on mobile. Paste\ndistributes a copied code across all cells; backspace and arrow keys\nnavigate between them.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-auth-otp-segmented-input.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"67a31b45-e1e8-4a77-813d-90e77d7cab76","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-recent-accounts-and-cross-tab","type":"added","scope":"web","summary":"Login now remembers your previous accounts on this device, promotes your last sign-in method, and syncs across tabs.","body":"A cluster of returning-user upgrades on `/login`:\n\n- **Recent accounts on this device** — a Notion-style picker above\n  the form lists workspaces you've signed in to on this browser,\n  with a gradient avatar + last-used method chip. Click one to\n  prefill the email and focus the password. Per-row \"Forget\" or\n  bulk \"Clear all\".\n- **Last-method promotion** — when the typed email matches a\n  remembered account, the matching sign-in path (Google, Microsoft,\n  passkey, etc.) floats to the top with a \"Last used\" badge.\n- **WebAuthn conditional UI** — the email field now declares\n  `autocomplete=\"username webauthn\"` and probes for passkey\n  autofill on mount, so eligible passkeys appear inline in the\n  browser's autofill drawer — zero clicks for return visitors with\n  a passkey.\n- **URL email pre-fill** — `/login?email=alex@acme.com` (used by\n  invitation deep-links and \"you've got an account\" suggestions)\n  populates the field on mount, letting the smart-hint pipeline\n  fire immediately.\n- **Cross-tab sign-in detection** — when a tab signs in, every\n  other open `/login` tab snaps to the destination automatically\n  instead of sitting on a now-pointless form.\n- **Forgot-password promotion** — after two failed attempts in the\n  same session, the \"Forgot password?\" link grows into a more\n  visible \"Reset password instead?\" chip.\n- **Friendlier auth errors** — server-side state errors (account\n  suspended, org suspended, email not verified, captcha failure,\n  rate limited) map to specific actionable messages instead of\n  the generic \"Sign-in failed\".","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-auth-recent-accounts-and-cross-tab.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5446a94a-18af-438b-8c9d-4636545730b8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-stuck-login-fix","type":"fixed","scope":"web","summary":"Fixed a race where sign-in occasionally kept the user on /login or flashed back to it after redirect.","body":"After a successful sign-in, the post-auth helper used to invalidate\nthe React Query cache and ask the router to re-run its auth gate.\nBut `ensureQueryData` returns cached data even when it's stale (the\nstale-while-revalidate contract), so the gate kept reading the\npre-login `false` value while the background refetch was still in\nflight — long enough for the navigation to bounce back to `/login`.\n\nThe fix is to write the gate to its new truth (`true` for sign-in,\n`false` for sign-out) before invalidating anything else. The router\nnow observes the post-auth state on the very next `beforeLoad` pass\nwithout depending on a refetch race.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-auth-stuck-login-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5f55f7a2-b752-4606-8b38-abaf1e641d78","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-payroll-bank-form-fix","type":"fixed","scope":"hrm","summary":"Payroll bank account form now saves cleanly and keeps stored numbers on edit.","body":"Editing a payroll bank account used to throw \"Invalid input\" when the org\nhad no default currency configured, and forced HR to retype the account\nnumber on every edit (even when they only wanted to change the routing\nnumber or label). The dialog has been rebuilt into a sectioned form\n(Identity / Account / Routing / Settings), the account number now stays\noptional in edit mode with the current masked number visible for\nreference, and the split is shown as a percentage instead of basis\npoints. Currency, country, and other optional fields no longer choke on\nempty values.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-hrm-payroll-bank-form-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d8468da6-3823-40ea-95fd-b044c326b74e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-payroll-bank-form-polish","type":"changed","scope":"hrm","summary":"Payroll bank accounts gained split-allocation summary, country flags, and inline primary-demotion warnings.","body":"The payroll bank-account section in the employee detail page now surfaces\ninformation that used to require mental arithmetic. The list shows a\nsingle banner reporting whether the splits across all accounts total 100%\n(green) and warns when they're under- or over-allocated. Each card carries\na tiny progress bar for its share of pay and a country-flag glyph\nalongside the currency. The add/edit dialog now warns by name when saving\nwill demote another account from primary, suggests the country's typical\ncurrency as a one-click chip when they don't match, auto-focuses the bank\nname on open, and keeps the Save/Cancel buttons pinned to the bottom of\nthe dialog as you scroll long forms. Delete now confirms with the bank\nname and masked number rather than a generic \"Delete this bank account?\".","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-25-hrm-payroll-bank-form-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2658ca4e-a698-45c9-b4dd-0acdff2c2a1e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-email-otp-signin","type":"added","scope":"web","summary":"Email-OTP sign-in path — get a 6-digit code by email, no password required.","body":"A new passwordless sign-in path lands alongside magic-link and\npassword/passkey/OAuth/LDAP on `/login`. Users type their email,\nreceive a 6-digit one-time code via email, then enter it in a\nsegmented OTP input. Auto-advance, auto-submit, error shake, and\n30s resend cooldown — same polish as the 2FA challenge.\n\nEnv-gated by `HELIOS_EMAIL_OTP_ENABLED`; surfaces via the public\n`/api/auth-providers` snapshot so the UI lights the right method\nbuttons. Mail delivery flows through the unified email module via\na new `auth.sign_in_otp` template + flow registry entry.\n\nBacked by Better-Auth's `emailOTP` plugin with `disableSignUp: true`\n(so the flow never silently creates a new account from an\nunrecognised email), default 6-digit / 5-minute TTL, and 3-attempt\nlimit per code.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-26-auth-email-otp-signin.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e516bc8c-ca03-4150-ac01-c5c3b5127be0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-oauth-magiclink-claim","type":"fixed","scope":"web","summary":"OAuth and magic-link sign-ins now populate the on-device account picker for next visit.","body":"Previously the RecentAccountsPicker on `/login` only learned about\npassword and passkey sign-ins (the in-tab paths). OAuth and magic-\nlink round-trip through an external surface, and we never wrote\nthe user back into on-device memory once they landed authenticated.\nReturning users who relied on Google or magic-link never saw their\naccount in the picker.\n\nFix: drop a `sessionStorage` claim marker before the OAuth /\nmagic-link redirect, and consume it once the AppShell mounts\nauthenticated. The matching `rememberSignInFromMe()` call writes\nthe email + display name + avatar + method into the picker, so\nthe next visit shows \"Continue as alex@acme.com — Last used:\nGoogle\" exactly like the password path.\n\nCross-device magic-link clicks (the link opens in a different\nbrowser than where it was requested) still fall through silently —\nsessionStorage doesn't follow you across devices. Those users get\nrecorded on their next password sign-in, same as before.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-26-auth-oauth-magiclink-claim.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"8eb8edf0-da5d-4148-b174-d6c102c62681","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"saas-auth-providers-overview","type":"changed","scope":"web","summary":"Auth-providers admin gets a status overview + email-OTP toggle + sectioned feature flags.","body":"`/saas/auth-providers` now opens with a \"Currently active\" overview\ncard listing every sign-in method live on `/login` as colour-coded\npills (OAuth/SAML/LDAP/passwordless/safety nets), so root operators\nsee the platform's auth surface at a glance instead of scrolling\nthrough cards.\n\nThe Feature toggles panel is reorganised into three sections —\nPasswordless sign-in (magic-link, email-OTP, SMS), Security &\nbreach detection (HIBP + min-occurrences threshold), and\nCompliance posture (HIPAA, PCI) — with the new email-OTP toggle\nthat pairs with the freshly-landed sign-in path. The HIBP min-\noccurrences threshold is now editable inline (it's only shown\nwhen HIBP is on).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-26-saas-auth-providers-overview.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"43246cd1-aa28-4a16-9c59-b9f6dd7d41a8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"settings-security-login-history-polish","type":"changed","scope":"web","summary":"Login history surfaces \"This device\" + \"New device\" badges and a stats strip so anomalies stand out.","body":"The Login history card on `/settings/security` now highlights which\nrow is the visitor's current session (\"This device\" tag, accent\nicon halo) and flags the first occurrence of every unique\n(IP, user-agent) combination as a \"New device\" (\"first time we saw\nthis fingerprint\"). Spotting an unfamiliar sign-in is now visual,\nnot a manual walk through 50 rows.\n\nA new header strip above the timeline shows total sign-ins, unique\ndevices, and how many sessions are still active right now — the\n\"Slack-style\" at-a-glance summary that lets a security-conscious\nuser catch outliers in two seconds.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-26-settings-security-login-history-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"9c22ec5b-3edb-411c-b52b-fbd139738c86","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"settings-security-score","type":"added","scope":"web","summary":"/settings/security now opens with an account-security score card listing every protection and the missing ones.","body":"A new top-of-page summary on `/settings/security` totals every\nprotection the user has enabled (email verified, 2FA, passkey,\nstrong password, session hygiene) and surfaces the gaps as\none-click actions. The verdict is colour-coded — Strong / Good /\nNeeds work — with an animated progress bar.\n\nEach missing step has a \"Verify now\" / \"Enable\" / \"Add passkey\" /\netc. button that scrolls the matching detailed card into view (or\ndeep-links to the right setting page). The card self-hides for\nfully-hardened users to keep the page uncluttered.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-26-settings-security-score.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"00789b90-73b0-491b-b970-a10455b4da98","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-itemlist-jsonld","type":"changed","scope":"marketing","summary":"Phase 20 — ItemList JSON-LD on /product and /integrations for richer SERP results.","body":"Index-style pages (the ones that list many things) now emit an\n`ItemList` schema graph in addition to the existing `BreadcrumbList`.\nThis documents the page's logical contents to crawlers and may\nsurface as a bulleted \"Items on this page\" snippet in some SERPs.\n\n- `s.itemList(name, items)` added to the schema helper in `lib/schema.ts`\n  — generic, so any future index page can opt in with one line.\n- `/product` — flattens the 4 groups × ~3-4 modules each into 14\n  entries, each linking to `/product/<slug>`.\n- `/integrations` — flattens the 5 shipped categories into the\n  per-integration list, excluding the \"Coming\" category so SERPs\n  don't surface unshipped items.\n\nThe breadcrumb root + per-item URLs all derive from\n`branding.marketingUrl`, so white-label deployments emit the\noperator's domain throughout the graph.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-itemlist-jsonld.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"87d5243b-2213-49d8-a286-0621b060ab93","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"apply-page-dark-mode","type":"changed","scope":"forms","summary":"Public /apply form route — loading/error/success states use design tokens instead of bare Tailwind shades; survives dark mode.","body":"The public form route at `/apply/:orgSlug/:formKey` had three states\n(`loading`, `error`, `submitted`) using bare Tailwind shade tokens\n(`bg-neutral-200`, `bg-red-50 border-red-200 text-red-900`,\n`bg-emerald-50 border-emerald-200 text-emerald-900`). These look fine\non the default white-page render but break in dark mode: the muted\nbackgrounds become near-white-on-near-black, and the saturated text\nshades lose contrast.\n\nConverted to the CSS-variable design tokens the rest of the app uses\n(`--bg-default`, `--bg-elevated`, `--border-default`,\n`--accent-danger-fg`, `--accent-success-fg`, etc.) so the three\nstates render correctly under both color schemes.\n\nTwo small a11y improvements bundled in:\n\n  - The error card gains `role=\"alert\"` so screen readers announce\n    the failure on appearance.\n  - The success card gains `role=\"status\"` / `aria-live=\"polite\"`\n    matching the convention used by `/help/contact` + the form\n    success-card pattern.\n\nThe success check-mark switched from a single `✓` glyph to an inline\nSVG polyline so it renders consistently across all fonts (the glyph\nwas rendered as a 2xl emoji-style symbol on some platforms).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-apply-page-dark-mode.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"3302245a-bedd-4e4c-b553-7410dd7d391a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"auth-icon-weights-premium","type":"changed","scope":"web","summary":"Auth surfaces switch field icons to crisp hairline stroke + chrome to bold for a more refined feel.","body":"The auth surfaces (login, signup, forgot-password, reset-password,\nverify-email, step-up, smart-email hint, recent-accounts picker,\nsecurity-score card, magic-link, email-OTP, LDAP) previously used\nPhosphor's `duotone` weight uniformly — a soft two-layer fill that\nread as cartoony at small sizes. The new weight tiering is:\n\n- **`regular`** (hairline stroke) for field-leading icons at sizes\n  15–16 — gives the Linear / Stripe aesthetic at the input row.\n- **`bold`** for chrome chevrons / arrows / carets / tiny pill\n  icons at sizes 11–14 — crisp directional cues.\n- **`fill`** for state icons (Warning, Crown, ShieldCheck role\n  badges) where the solid shape is the signal.\n- **`duotone`** retained for hero moments at sizes 20+ where the\n  layered look reads as crafted (success checkmark, mail-sent\n  envelope, shield in step-up).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-auth-icon-weights-premium.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"73fbee50-1e70-4f1a-ad48-375372e4ecc6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"careers-empty-state-icon","type":"changed","scope":"recruitment","summary":"/careers list — empty-state card gains a Briefcase icon for visual parity with other help/changelog empty states.","body":"The public `/careers` empty state (used for both \"no openings\" and\n\"no roles match these filters\") was a dashed-border card with two\nlines of muted text. Added a duotone `Briefcase` icon at 32px above\nthe title so the card reads at a glance — matches the convention\nused by `/help/whats-new`, `/help/changelog`, `/help/$category`,\n`/help/services`, `/help/services/$slug`, and the help index\nsearch-empty state.\n\nSame `EmptyState` component is used by both empty branches on the\ncareers list, so this lights up both copy variants in one pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-careers-empty-state-icon.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e694cfaa-ae45-4035-abdb-650035deb7bf","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"careers-withdraw-polish","type":"changed","scope":"recruitment","summary":"/careers/withdraw — SVG checkmark, focus ring on textarea, inline spinner on submit, error role=alert.","body":"Small polish pass on the public application-withdraw page.\n\n  - Success card swaps the `✓` glyph for an inline SVG polyline so\n    the check renders consistently across all platforms (the glyph\n    was rendered as a 2xl emoji on some). Card gains\n    `role=\"status\"` / `aria-live=\"polite\"` for SR announcement.\n  - Textarea gains a 2px focus ring + transition-colors matching the\n    other public forms (`/help/contact`, `/help/services/$slug`).\n  - Submit button gains focus-visible ring with offset, hover-opacity\n    transition, and an inline spinner during `Withdrawing…`.\n  - Error message gains `role=\"alert\"` so SRs announce on appearance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-careers-withdraw-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b19b3e95-6377-4ffd-8ff3-05364e71b8bb","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-activity-composer","type":"added","scope":"clients","summary":"Inline activity composer on the client detail Activity tab — log a note / call / meeting / email without leaving the page.","body":"Phase H polish slice from `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The Activity tab on `/clients/$id` now exposes an inline composer at the top of the timeline. The empty state collapses to a one-line \"+ Log an activity\" affordance; clicking it opens the form inline (no modal).\n\n- Kind chips: note · call · meeting · email\n- Optional subject + free-form body (auto-grows)\n- Submits to `crm.activity.create` and invalidates the timeline query so the new row lands at the top\n\nReplaces the previous read-only feed where CS reps had to navigate to CRM › Activities to log anything. Pairs with the existing event-driven feed entries from CRM, sales, and engagement-status changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-activity-composer.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5fc5aa66-0c9c-437f-8dbc-3566b7df2d2c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-nav-progress-and-leak-fix","type":"changed","scope":"marketing","summary":"Phase 34 — Navigation-progress bar during View Transitions + fix a magnetic-CTA pointer-listener leak that grew on every page swap.","body":"Two complementary fixes on the View Transition layer:\n\n### 1. Navigation-progress indicator\n\nAstro 5's `<ClientRouter />` emits `astro:before-preparation` /\n`astro:after-swap` lifecycle events for every same-origin nav. The\nvisitor sees a brief silent pause while the new page loads — no\nvisual feedback. Re-used the existing reading-progress bar element\n(`#scroll-progress`) to render a 0→70% sweep on `before-preparation`\nand snap-to-100%-then-fade-out on `after-swap`.\n\nTo avoid the scroll handler and nav handler fighting for control of\nthe same bar, the nav handler tags the element with\n`.is-navigating` for the duration of the sweep and the scroll\nhandler skips updates while that class is present. After the fade,\ncontrol hands back to scroll.\n\nThe bar uses the existing primary-gradient styling. ~360ms total\nanimation; visually distinct from a slow scroll because it sweeps\neven when the visitor hasn't scrolled.\n\n### 2. Magnetic-CTA pointer listener leak\n\n`wireBentoInteractions` was attaching `window.addEventListener\n('pointermove', …)` **once per `[data-magnetic]` element** and\nre-running on every `astro:after-swap`. A page with 5 magnetic\nCTAs leaked 5 stale window listeners per navigation; after 10\nnavigations the page was dispatching 50 pointermove handlers per\nmouse move, all referencing GC-pinned closures.\n\nRefactored to a single shared window listener that walks\n`document.querySelectorAll('[data-magnetic]')` on each move. The\nshared listener installs once per session via a `__heliosMagnet`\nwindow flag. Per-cell tilt listeners still attach to the cells\nthemselves (auto-released with the DOM); they're now gated by a\n`data-bento-wired` marker so we don't double-bind on re-swap.\n\nNet: pointermove handlers stay at ~1 + N-cells-on-current-page\nno matter how many navigations the visitor makes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["classroom-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-nav-progress-and-leak-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"500cecf6-d5e1-4ed7-9e06-749b9cc57d1c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-ai-brief","type":"added","scope":"clients","summary":"New clients.client.ai_brief action — single-call AI-friendly briefing of a client's billing + engagements + support + health.","body":"Phase G4 of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. A new action `clients.client.ai_brief` lets the AI agent pull everything it needs about a single client in one round-trip when an operator opens a chat side-panel or pulls up a record mid-call.\n\nOutput shape is deliberately agent-friendly:\n\n- `headline` — one-line lead the agent can read aloud (e.g. \"Acme Co · customer · 2 active engagements\").\n- `summary` — 1-3 sentences composed from the open-balance, MRR, renewal, support, and last-activity signals. Falls back to \"Healthy customer, no open work to flag.\" when nothing's noteworthy.\n- `facts` — pre-rendered money strings (`openBalanceLabel`, `monthlyRunRateLabel`, `lifetimeInvoicedLabel`, `overdue60PlusLabel`) so the agent doesn't have to run a formatter; counts, ticket totals, score, signals.\n- `suggestedActions` — verb-led phrases the agent can offer as chips (\"Chase the USD 1,200 60+ overdue balance\", \"Send a renewal quotation\", \"Open the aging support ticket\"). Only emits actions the operator can act on confidently.\n\nInternally fans out to `clients.client.get` + `clients.client.health` (v2) so the cross-module aggregation logic lives in one place. The brief action is a composer, not a separate aggregator.\n\nRead-only; inherits the actor's permissions via the underlying read policies.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-ai-brief.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"05665b08-1ea0-4f76-b333-101b01c22b24","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-health-v2-ui","type":"changed","scope":"clients","summary":"Client detail surfaces the new health-v2 signals — overdue renewals + aging/volume tickets — with the same warning-tone treatment as overdue invoices.","body":"UI side of the Phase D backend (commit `f4dec05f`). The client detail's health card now renders the four new outputs:\n\n- **Renewals overdue** line surfaces when the client has engagements past renewalDate without churn/completion. Warning tone.\n- **Open tickets** line surfaces when the support module has flagged any open tickets for the company. Shows the total + a subdued \"(N aging)\" callout when any of those tickets has been silent for 7+ days.\n- **Signal chips** for the four new codes (`renewal_overdue`, `support_tickets_aging`, `support_tickets_volume`, plus the existing renewal-in-30) render with warning-tone dots so the operator sees at a glance which yellow-flips were driven by post-sale workflow vs financial signals.\n- Labels mapped in the existing `SIGNAL_LABEL` registry: \"Renewal past due\", \"Support tickets aging (7d+)\", \"High open ticket volume\".\n\nDefaults to hidden when the counts are 0, so single-engagement / no-tickets accounts don't accumulate empty rows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-health-v2-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"701c666c-9344-4f9b-9452-d0e425629240","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-hub-currency-polish","type":"changed","scope":"clients","summary":"/clients hub shows each company's open balance in its own currency, not a misleading $-prefix.","body":"`/clients` — the top-level customer hub, not `/sales/clients` — was the last sales-adjacent surface still rendering money with a hard-coded `$` prefix. Per-row open balances + the delete-confirmation total now go through `formatMoney(cents, c.currency)` so a PKR-billing customer sees `PKR 100,000.00` instead of `$100,000.00`.\n\nCloses the cross-module currency-display audit for this round.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-hub-currency-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"10336d6b-1858-438a-848e-47e8d50d1831","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-keyboard-shortcuts","type":"added","scope":"clients","summary":"Keyboard shortcuts on /clients hub — `/` focus search, `j`/`k` navigate rows, `Enter` open, `x` toggle select, `Escape` close.","body":"Phase H polish on `/clients`. The hub now supports keyboard navigation matching the standard inbox-style pattern operators expect:\n\n| Key | Action |\n|---|---|\n| `/` | Focus the search input (works from anywhere on the page) |\n| `j` / `↓` | Move focus to the next row |\n| `k` / `↑` | Move focus to the previous row |\n| `Enter` | Open the focused row's detail |\n| `x` | Toggle the focused row's selection (pairs with bulk ops) |\n| `Escape` | Close the create/edit sheet → close the delete confirm → clear row focus |\n\nThe focused row gets an inset `ring-1` accent border so the operator always knows which row Enter will open. The focused row auto-scrolls into view, so j-spamming through a long list always keeps the active row visible.\n\nShortcuts skip whenever the user is typing in an input/textarea/select/contenteditable — `/` is the one exception (also focuses search even from input, but only when no modifier keys are held so Cmd+/ stays reserved for the AI side-panel).\n\nFilter changes (kind / lifecycle / archived toggle / search query) reset the focus index so the cursor never points at a now-hidden row.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-keyboard-shortcuts.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f3776407-8b5b-4a06-99df-896fde7226e4","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-a-unified-hub","type":"changed","scope":"clients","summary":"/sales/clients folds into the unified /clients hub; /engagements gains a Renewing soon filter.","body":"First slice of the clients-module consolidation (Phase A of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`).\n\n- `/sales/clients` and `/sales/clients/$id` are now redirect stubs that bounce to `/clients` and `/clients/$id`. The unified hub already carries the full feature set (overview, contacts, engagements, sales, projects, support, activity, documents, portal) so the sales-side duplicate was pure overlap. Existing bookmarks + email deep-links survive.\n- `/engagements` gained a `renewingSoon=true` search param that narrows the list to engagements with `renewalDate` in the next 30 days, excluding already-churned / completed rows. Wires the new \"Renewing soon\" sub-nav entry under the Clients module to the dedicated renewal workflow.\n- Sales sub-nav already drops the duplicate `Clients` entry (lives under the Clients module now); the CRM funnel keeps its `Companies` entry as the pre-sale view per the dual-front-door model the plan documents.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-a-unified-hub.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"6309de64-8e94-4422-8155-42a2f1aca148","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-b-renewal-banner","type":"added","scope":"clients","summary":"Engagement detail shows a renewal-forecast banner with a one-click renewal-quotation CTA when renewal date is within 60 days.","body":"Phase B slice of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The engagement detail at `/engagements/$id` now renders a renewal-forecast banner above the KPI strip whenever the engagement is still actionable (prospect / active / paused) and renewalDate is within 60 days.\n\nThree visual states by days-out, no banner beyond 60 days:\n\n| Window | Tone | Copy |\n|---|---|---|\n| Overdue (renewalDate < today) | danger | \"Renewal was N days ago.\" |\n| 0–30 days | warning | \"Renewal in N days.\" |\n| 31–60 days | accent | \"Renewal in N days.\" |\n\nBanner exposes a \"Generate renewal quotation\" action that drops the operator on `/sales/quotations?companyId=…` with the client pre-filled — fastest path to \"clone the last quote and resend\".\n\nPairs with the `?renewingSoon=true` filter on `/engagements` (shipped in Phase A) so account managers can fan out from the list to each engagement's renewal action in one click each.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-b-renewal-banner.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2394699c-1a2b-46de-8d10-e5aa6ed6e6de","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-b-sub-attach","type":"added","scope":"clients","summary":"Attach-to-engagement block on the subscription detail page (parity with invoice + quotation detail).","body":"Phase B slice of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The subscription detail at `/sales/subscriptions/$id` now renders the same `EngagementLinkBlock` already shipped on invoice + quotation detail, so a recurring billing schedule can be attached to a client engagement (retainer / SaaS deal / maintenance contract) without leaving the page.\n\nCloses the parity gap — sales artifacts can all flow into the engagement's linked-artifacts roll-up now.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-b-sub-attach.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"518577dd-f04a-49eb-9f54-2c55f9ceb74a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-c-renewals-queue","type":"added","scope":"clients","summary":"New /clients/renewals workflow — horizon chips + per-row + bulk renewal actions.","body":"Phase C of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. A dedicated renewal-chasing workflow at `/clients/renewals` complements the `?renewingSoon=true` filter on `/engagements`:\n\n- **Horizon chips** at the top — Overdue · Next 7 days · Next 30 days · Next 60 days. Click any chip to narrow the table.\n- **Per-row actions** — `Quote` deep-links into `/sales/quotations?companyId=…` to clone the last quote · `Churn` flips the engagement's status to churned with one click.\n- **Multi-select** with a sticky \"Mark churned\" bulk action when more than one row is selected.\n- **Sort** by renewalDate ascending so the most urgent renewals are always at the top.\n\nReuses `clients.engagement.list` (no new backend) — horizon slicing happens client-side. Single-row + bulk churn fan out to the existing `clients.engagement.set_status` action.\n\nWired into the Clients module's sub-nav under the Engagements group, alongside the existing \"Renewing soon\" filter link.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-c-renewals-queue.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e7e6a418-1bb5-4533-bca3-ab877202038c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-d-health-v2","type":"changed","scope":"clients","summary":"Health score v2 — support-ticket velocity, overdue renewals, richer signals.","body":"Phase D of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The `clients.client.health` action gains four new inputs to the score derivation:\n\n- **Open support tickets** — joins `support_tickets` filtered to `treats_as IN ('open','pending','on_hold')` and counted per client. Surfaces as `openSupportTicketCount` on the output. Defensive try/catch so an org without the support module loaded never breaks the read.\n- **Aging support tickets** — same set, additionally filtered to rows whose `updatedAt` is older than 7 days. Surfaces as `agingSupportTicketCount` + drives a new `support_tickets_aging` signal that flips health yellow.\n- **Volume signal** — `>= 5` open tickets drives `support_tickets_volume` (also yellow). Even when each ticket resolves quickly, a high open count signals friction in the relationship.\n- **Overdue renewals** — engagements whose `renewalDate` is already past while still `prospect / active / paused`. Counted into a new `renewalsOverdue` field + drives a new `renewal_overdue` signal that flips health yellow. Distinct from the existing `renewal_in_30` upcoming-window signal so the dashboard can call out \"already missed renewal\" vs \"approaching renewal\".\n\nAlso opens up `pausedEngagementCount` to count toward \"still actionable\" so paused engagements count their renewal dates correctly toward the queue.\n\nNo new tables. Output is additive — pre-feature consumers parse fine because the new fields default to 0.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-d-health-v2.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7b1b6ee6-75bd-4a88-9fd0-a9bb5ee9e7f3","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-g3-support-panel","type":"added","scope":"clients","summary":"Support tickets panel on client detail — open/pending/on_hold tickets surfaced under a new Support tab.","body":"Phase G3 of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The client detail at `/clients/$id` gains a Support tab that shows open / pending / on_hold tickets filed by this client, sorted by last activity.\n\n- Lazy query — only fires when the tab is opened, so a client with no tickets doesn't round-trip on every detail-page hit.\n- Tab count badge mirrors `openSupportTicketCount` from the v2 health action so the operator sees the load at a glance before clicking through.\n- Per-row deep-link into `/support/$ticketId`. Empty state nudges to `/support` for resolved + closed history.\n- Tone dot per row: warning for `open`, lighter warning for `pending`, faint for `on_hold`.\n\nReuses `support.ticket.list` with the existing `requesterCompanyId` + `statusKeys` filters — no new backend.\n\nCloses the support side of the cross-module deepening from the plan; pairs with the existing `support_tickets_aging` / `support_tickets_volume` health signals (Phase D).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-g3-support-panel.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"971c42d4-b2bf-4f5d-85b7-8b3fca113b57","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-og-absolute-urls-dimensions","type":"changed","scope":"marketing","summary":"Phase 29 — promote ogImage to absolute URL + add og:image:width/height/alt + og:locale.","body":"Open Graph + Twitter Cards require **absolute** URLs in\n`og:image` — relative paths (`/og/pricing.png`) don't render\ncorrectly in LinkedIn, Slack unfurl, Twitter, Mastodon, or any\npreview-fetching client that doesn't resolve relative URLs the\nsame way browsers do.\n\nBaseLayout now resolves `ogImage` against the page `canonical` via\n`new URL(ogImage, canonical).toString()` so every social-share\npreview gets a guaranteed-absolute URL.\n\nThree more `og:image:*` tags emitted:\n\n- `og:image:width` = `1200` — matches our Satori-rendered OG cards.\n- `og:image:height` = `630`.\n- `og:image:alt` = `${title} — ${appName}` — describes the card for\n  screen readers using the link unfurl content.\n\nPlus `og:locale` = `${lang}` (BCP-47, e.g. `en-US`) so unfurl clients\ncan pick the right preview language when the operator's content is\nlocalised.\n\nAnd `twitter:image:alt` for the same a11y reason on the Twitter card.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-og-absolute-urls-dimensions.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"989cfb56-7f7f-492f-a81c-aec2678275cf","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-h-bulk-ops","type":"added","scope":"clients","summary":"Multi-select + bulk lifecycle change + CSV export on /clients hub.","body":"Phase H polish from `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The unified clients hub at `/clients` now supports operator bulk ops:\n\n- **Multi-select** — checkbox on every row + an indeterminate-aware \"select all visible\" header. Selection persists across filter changes so a CS rep can sweep through narrow queries (lifecycle filter, kind filter, search) and accumulate a batch.\n- **Bulk lifecycle change** — when one or more rows are selected the action bar exposes \"Mark prospect / customer / partner / churned\". Fan-out is client-side (iterates the existing `clients.client.set_lifecycle` per id) so the action contract stays narrow.\n- **CSV export** — top-right \"Export CSV\" button (current filtered set) plus \"Export selected\" inside the bulk bar. UTF-8 with BOM so Excel-on-Windows opens cleanly. 12 columns: name, code, kind, lifecycle, status, billing email, domain, currency, payment terms, open invoice count, open balance, contact count.\n\nNo new backend. Reuses `clients.client.set_lifecycle` + `clients.client.list`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-h-bulk-ops.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b3984d74-3dc7-4a52-978d-be050baf2008","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-phase-i-plan-limit","type":"added","scope":"clients","summary":"clients.client.create now enforces a plan-driven clients_limit cap, returns quota_exceeded with an upgrade-prompt message when reached.","body":"Phase I of `docs/plans/CLIENTS_MODULE_OVERHAUL.md`. The clients module becomes a clean SaaS upsell vector.\n\n- **New feature key `clients_limit`** in the plan catalog (`modules/saas/src/lib/feature-catalog.ts`). Number-kind, category `limits`. Defaults per plan:\n  | Plan | Cap |\n  |---|---|\n  | Free | 5 |\n  | Starter | 50 |\n  | Business | 500 |\n  | Enterprise | Unlimited |\n\n  Renders on the pricing-page comparison table via `formatForDisplay()`.\n\n- **`checkClientsQuota` helper** in `client-create.ts` resolves the limit through the existing `saas.limits.get_for_org` action (no hard dep on `@helios/saas`). Counts non-deleted rows (archived still count — archive doesn't free a slot; the operator deletes to reclaim).\n\n- **`clients.client.create`** runs the quota check before insert. When `used >= limit` returns `quota_exceeded` with an upgrade-prompt message: \"Plan cap of N clients reached. Archive an existing client or upgrade your plan to add more.\"\n\n- **`null` (unlimited)** at any layer falls through gracefully — best-effort gate, never blocks a write when the saas module isn't loaded (unit tests, scripts).\n\nTenants currently provisioned with a `clients_limit` of `null` in their plan jsonb stay unrestricted — the default value semantics from the feature catalog only kick in when an admin re-saves the plan via the editor (or a future backfill migration is run).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-phase-i-plan-limit.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"31999337-465f-4706-b0e8-0a1b4958cdfd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-saved-views","type":"added","scope":"clients","summary":"Saved-view chips on /clients — recall favourite filter combos with one click; per-org localStorage MVP.","body":"Phase H polish on `/clients`. Operators can save the current filter combination (kind + lifecycle + search query + show-archived toggle) as a named view, surfaced as a chip strip above the filter row.\n\n- **+ Save current view** button appears whenever any filter is active. Prompts for a name (auto-suggests something useful like `Customer · Companies` or `Prospect · \"acme\"`).\n- **Chip click** recalls every filter value verbatim — URL-search-aware fields go back through the router, local-only fields (search query, show-archived) restore inline.\n- **`×` per chip** removes it.\n- **Per-org localStorage** keyed by `helios.clients.savedViews.<orgId>` — survives reloads, never crosses orgs in the same browser. Quota / private-mode failures are absorbed silently.\n\nSchema-free MVP. When a future `user_saved_views` table lands the storage layer swaps under the same hook with no UI change — that migration is queued behind the higher-priority client-detail tabs work.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-clients-saved-views.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"722a171d-3b03-4c62-af79-37846774ab22","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"help-category-article-services-polish","type":"changed","scope":"support","summary":"Help category, article-detail, and service-catalog pages get skeleton loaders, friendlier empty states, count chips, and breadcrumbs.","body":"Continued polish pass across `/help/$category`, `/help/$category/$slug`,\nand `/help/services`.\n\n**Category landing (`/help/$category`):**\n\n  - Article-count chip next to the heading (\"N articles\") so visitors\n    see scope at a glance.\n  - \"Loading…\" text replaced with a 5-row skeleton inside the same\n    bordered container the article list renders into.\n  - Empty state gets a Folder icon and is wrapped in a flex-column\n    layout (consistent with the empty states on `/help` and\n    `/help/whats-new`).\n  - Article rows show an arrow-right indicator that fades in on\n    hover, signaling the row is clickable.\n\n**Article detail (`/help/$category/$slug`):**\n\n  - \"Loading…\" text replaced with a full-page skeleton mirroring the\n    article shape (eyebrow + h1 + meta line + 4 paragraph lines).\n  - Not-found error becomes a dashed-border card with secondary hint\n    copy + a styled \"Back to help center\" button instead of a single\n    muted line with a text link.\n\n**Service catalog (`/help/services`):**\n\n  - `← Help center` breadcrumb above the title for back-navigation\n    parity with `/help/changelog`.\n  - Service-count chip next to the heading.\n  - 6-card grid skeleton replaces the bare \"Loading…\" text.\n  - Empty state gets a Storefront duotone icon.\n  - Cards gain a focus-visible ring + arrow-right that slides right\n    on hover, matching the affordance pattern used on\n    `/help/$category`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-help-category-article-services-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"275f14d6-d41a-42ee-9364-842505c9022b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"help-changelog-polish","type":"changed","scope":"support","summary":"/help/changelog gets month grouping, breadcrumb, RSS icon, skeleton loader, friendlier empty state.","body":"Polish pass on the per-tenant `/help/changelog` page.\n\n  - **Month grouping** — entries previously rendered as a single flat\n    list. Now grouped by `YYYY-MM` with a sticky-style header carrying\n    the localized month name + an entry count chip (\"3 entries\").\n    Matches the convention used by the `/help/status/maintenance`\n    page's \"Past 90 days\" section.\n\n  - **Help-center breadcrumb** — small `← Help center` eyebrow above\n    the page title. Tenants navigating in via the quick-link strip on\n    `/help` now have a back-link without relying on the browser stack.\n\n  - **RSS icon** — the bare \"RSS\" button text gains a duotone RSS\n    icon + a hover-bg treatment + `aria-label` for screen readers.\n\n  - **Skeleton loader** — replaces the \"Loading…\" text with a 3-row\n    skeleton block matching the eventual layout.\n\n  - **Friendlier empty state** — bare \"No entries yet\" line becomes a\n    dashed-border card with a Newspaper icon + secondary hint copy,\n    matching the convention used on `/help` and `/help/whats-new`.\n\nNo behavior or API changes — pure UI polish.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-help-changelog-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"0c4f1dce-862c-4be8-90a8-32d2a3c638b3","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"help-docs-polish","type":"changed","scope":"support","summary":"/help/docs/$slug — full-page skeleton loader, dashed-border not-found card with BookOpen icon.","body":"Polish pass on the public block-rendered docs route. Two empty/load\nbranches were bare text; both now match the convention used across\nthe other help routes.\n\n  - **Loading state** — bare \"Loading…\" → full-page skeleton mirroring\n    the article shape (eyebrow + h1 + meta + 4 paragraph lines).\n  - **Not-found state** — bare card with a single line → dashed-\n    border centered card with a `BookOpen` duotone icon, friendlier\n    title, secondary hint copy, and a properly-styled \"Back to help\n    center\" button.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-help-docs-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"4c78123e-a735-4bf5-ba2f-d38a70a35fe0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"help-pages-polish","type":"changed","scope":"support","summary":"Help center index + contact form get skeleton loaders, icon-accented quick links, focus rings, and a success-toned confirmation card.","body":"Two public-facing help-center pages polished.\n\n**`/help` (HelpIndex)**:\n\n  - Search input grows a leading magnifying-glass icon + a 2px focus\n    ring (was bare placeholder with only a border-color focus).\n  - Quick-link strip — `Platform status`, `What's new`, `Changelog`,\n    `Contact support` — gains a per-link duotone phosphor icon so the\n    row reads as a visual nav rail instead of four button-rectangles.\n    Each icon picks a tone from the existing palette (status = green,\n    what's-new = blue, changelog = neutral, contact = neutral).\n  - The \"Loading…\" text under Search results becomes a 3-row skeleton\n    inside the same bordered container the results render into.\n  - The \"Loading…\" text for category cards becomes a 6-card grid\n    skeleton matching the eventual layout, so the page doesn't jump\n    when categories arrive.\n  - Search empty state gets an icon + dashed-border treatment instead\n    of a single muted-text line.\n\n**`/help/contact` (ContactPage)**:\n\n  - All form inputs share one `INPUT_CLS` constant with proper\n    focus-ring styling (border-color flip + 2px ring) so the contact\n    form matches the rest of the app's input affordances.\n  - Submitted-success state gets a success-tone card (green border +\n    soft fill, 40px circular icon badge with duotone CheckCircle) and\n    `role=\"status\"` / `aria-live=\"polite\"` so screen readers announce\n    the ticket reference. The \"Back to help center\" + \"Send another\"\n    buttons gain transition-colors + improved hover.\n  - Submit button gets a focus-visible ring with offset, hover-opacity\n    feedback, and an inline spinner during `Sending…` state matching\n    the marketing subscribe form pattern.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-help-pages-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2a4620e0-ff3a-476f-8ce6-7f74afd5be2f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-header-z-index-fix","type":"fixed","scope":"marketing","summary":"Sticky top nav + mega-menu now stay above page content on scroll (z-index regression).","body":"The marketing site's sticky top nav was being painted **under** page\nsections as the visitor scrolled. Anything that creates its own\nstacking context — a section with `transform`, `backdrop-filter`,\n`opacity`, a `bg-gradient`, or a `mix-blend` overlay — would slip in\nfront of the header.\n\nRoot cause: the header markup carried `className=\"z-sticky\"`, but\n`tailwind.config.ts` never extended Tailwind's `zIndex` scale. Tailwind\nsilently dropped the unknown class, leaving the sticky bar at\n`z-index: auto`. Any later sibling with an explicit z-index won.\n\nFix: extended `theme.extend.zIndex` in `tailwind.config.ts` to mirror\nthe CSS variables already declared in `src/styles/tokens.css`:\n\n```ts\nzIndex: {\n  dropdown: '50',\n  sticky:   '100',\n  modal:    '200',\n  toast:    '300',\n  tooltip:  '400',\n}\n```\n\nConfirmed Tailwind now emits `.z-sticky { z-index: 100 }` and every\nrendered page's `<header>` carries it.\n\nAlso added an explicit `z-sticky` to the Radix\n`NavigationMenu.Viewport` wrapper so the desktop mega-menu dropdown\ntravels with the header's stacking context — survives any future\nheader restructure that breaks the implicit inheritance.\n\nThe mobile-nav drawer (`z-modal` = 200) and command palette (`z-modal`\n= 200) continue to overlay the header correctly.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-header-z-index-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"106bd63e-a0ca-4b24-acda-d53b5a2bcd22","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"help-services-detail-polish","type":"changed","scope":"support","summary":"/help/services/$slug — full-form skeleton loader, friendlier not-found state, success-toned submitted card, focus rings on inputs.","body":"Polish pass on the service-request form at `/help/services/<slug>`.\n\n  - **Loading state** — \"Loading…\" text replaced with a full-page\n    skeleton: header lines + 4 stacked field skeletons inside the\n    form card, mirroring the eventual layout so the page doesn't\n    jump when data arrives.\n\n  - **Not-found state** — was a bare card with a single line + a\n    text-link. Now a dashed-border centered card with a Storefront\n    duotone icon, a friendlier title, secondary hint copy, and a\n    proper styled back button — matching the convention used by\n    `/help/$category/$slug`, `/help/whats-new`, and others.\n\n  - **Submitted-success state** — was a plain card; now uses the\n    same success-tone treatment as the contact form (green border +\n    soft fill, 40px circular icon badge with CheckCircle, plus\n    `role=\"status\"` / `aria-live=\"polite\"` for SR announcement).\n    Both action buttons gain `transition-colors`.\n\n  - **Form inputs** — all inputs share an `INPUT_CLS` constant with\n    proper focus-ring styling (border-color flip + 2px ring) so the\n    service form matches the contact form's input affordances.\n    `renderField` uses the same class for textarea / select / email /\n    url / text variants.\n\n  - **Submit button** — gains focus-visible ring with offset, hover-\n    opacity, and an inline spinner during `Submitting…` (matching the\n    contact-form + marketing-subscribe patterns).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-help-services-detail-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"caa751c5-2c4b-4b3d-b16d-010870b9fbb4","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"help-status-maintenance-route-fix","type":"fixed","scope":"support","summary":"/help/status/maintenance route loaded with an `as any` cast that the TanStack router-generator parser now rejects.","body":"The `/help/status/maintenance` route used a one-tick workaround when\nit was first added — `createFileRoute('/help/status/maintenance' as any)`\n— under the assumption that the next dev tick would regenerate\n`routeTree.gen.ts` and the cast could come off.\n\nThe TanStack router-generator's parser has since tightened: it now\nrejects anything that isn't a plain string literal or plain template\nliteral for the route id, which means dev-server boot fails with:\n\n    Error transforming route file ...help/status.maintenance.tsx:\n    Error: expected route id to be a string literal or plain template\n    literal in /help/status/maintenance\n\nRemoved the `as any` cast. The next router-tree regeneration picks\nup the route automatically.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-help-status-maintenance-route-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a7beb39e-38cd-470d-83d5-6abc81b9220f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"login-separator-adoption","type":"changed","scope":"web","summary":"Login \"or with email\" divider migrates from hand-rolled markup to the new Separator label primitive.","body":"The \"or with email\" Section-divider between the OAuth / passkey\nquick-sign-in row and the credentials form was previously hand-\nrolled with two gradient `<span>`s flanking a `<span>` pill. Now\nuses the new `<Separator label=\"…\">` primitive — same visual\noutput, but every future divider on auth / onboarding pages\ninherits the same chrome by passing a single prop.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-login-separator-adoption.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f0141d35-67a3-4968-8497-31ec533cad9a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-favicon-theme-aware","type":"changed","scope":"marketing","summary":"Phase 21 — favicon.svg now theme-aware (light/dark) + drops the brand-purple hard-code.","body":"The fallback `favicon.svg` (used when an operator hasn't uploaded a\ncustom one) hard-coded `#7C3AED` for the background. Two issues:\n\n1. **White-label leak** — operators who'd otherwise inherit a neutral\n   chrome still saw a purple favicon in browser tabs.\n2. **Visibility** — purple-on-dark-Chrome read fine, but purple-on-\n   light-Safari clashed with the indigo theme-color, and the inner\n   white glyph disappeared on light backgrounds when the OS render\n   anti-aliases the tab pixel grid.\n\nSVG favicons support inline `<style>` with `@media\n(prefers-color-scheme: ...)` in modern browsers. The new favicon:\n\n- Light mode: indigo background (`#1E1B4B`) + white glyph — matches\n  the existing `theme-color` meta tag for the OS chrome.\n- Dark mode: white background + near-black glyph (`#0B0A1F`) —\n  flips so the glyph keeps contrast in dark Chrome tabs.\n\nOperators with `branding.faviconUrl` set keep using their uploaded\nicon (BaseLayout already prefers it); the static fallback is the\ndeploy-zero default.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-favicon-theme-aware.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a991e680-c749-472b-9abc-c4610ffe46a2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-opensearch-noscript","type":"changed","scope":"marketing","summary":"Phase 27 — OpenSearch descriptor (browser address-bar search) + no-JS reveal-on-scroll fallback.","body":"Two layers of \"make the site work for everyone\":\n\n- **/opensearch.xml** — OpenSearch 1.1 description document. Browsers\n  use this to let users register the site as an address-bar search\n  engine (Chrome Omnibox, Safari sidebar, Firefox search bar).\n  Visitors who add it can type `h pricing` (or whatever keyword they\n  pick) to query the site's Pagefind index from anywhere. Dynamic per\n  deployment — white-label installs register under the operator's\n  brand + URL. `<link rel=\"search\" type=\"application/opensearch\n  description+xml\">` added to BaseLayout's `<head>`.\n\n- **No-JS reveal-on-scroll fallback** — the `.reveal` opacity-0\n  default would hide every page section forever for visitors with\n  JavaScript disabled (a11y users, crawlers without a JS evaluator,\n  paranoid security setups). A `<noscript><style>` sheet now flips\n  `.reveal` and `.reveal-stagger > *` to fully visible when JS is off,\n  so the content renders honest. Inline `!important` overrides the\n  default per-element transition so even residual transition queues\n  don't fade things back.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-opensearch-noscript.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"1e3e147c-74a4-416e-bda1-1d555e798569","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-plausible-leak-fix","type":"fixed","scope":"marketing","summary":"Fix `import.meta.env.PROD && PUBLIC_PLAUSIBLE_DOMAIN && ( )` text leaking onto every public page.","body":"A regression introduced in Phase 29 (OG absolute URLs) left two JSX\nconditionals that referenced `import.meta.env.*` directly:\n\n```astro\n{import.meta.env.PROD && import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN && (\n  <script .../>\n)}\n```\n\nAstro evaluates `import.meta.env` only inside the `---` frontmatter\nblock — Vite's compile-time replacement does **not** reach into JSX\n`{}` expression slots in `.astro` files. The expression was treated as\nopaque, the runtime evaluated `undefined && undefined && (...)` to\n`undefined`, and the surrounding text + parentheses leaked into the\nrendered `<head>` (and subsequently the visible top of the page when\nthe browser tried to flush invalid head content into the body).\n\nVisible symptom on every static page:\n\n> `mport.meta.env.PROD && import.meta.env.PUBLIC_PLAUSIBLE_DOMAIN && ( )`\n\nFix: hoisted `plausibleEnabled` + `plausibleDomain` to frontmatter\nconstants and replaced the broken JSX guard with `{plausibleEnabled && (...)}`.\nConfirmed via `grep \"import.meta.env\" dist/**/*.html` returning zero\nmatches across all 53 prerendered pages.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-plausible-leak-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"26027ff4-2da2-4b25-844a-88d0202dd9dd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-prefers-reduced-data","type":"performance","scope":"marketing","summary":"Phase 28 — honor prefers-reduced-data for low-bandwidth visitors (data-saver mode).","body":"When a visitor's browser advertises Save-Data / \"Reduce data usage\"\n(metered connection, mobile data saver, slow connection), CSS reports\n`prefers-reduced-data: reduce`. The site now drops the decorative\natmospheric paint when this fires:\n\n- `background-image: none` — kills radial back-glows, gradient\n  backgrounds, dotted-grid textures.\n- `box-shadow: none` — drops every card-lift / cta-lift shadow.\n- `filter: none` — strips the blur(8px) blobs + grain saturate filters.\n- `animation: none` — pauses all keyframe loops (hero ticker, bento\n  cells, pulse rings, etc.).\n- Decorative `aria-hidden` `<svg>` filters + atmospheric back-glow\n  spans hide via `display: none`.\n\nContent stays — typography, layout, structural borders are\npreserved. The page renders honest, just without the ambient\nflourishes that cost paint cycles + decode time. Roughly halves the\npaint work on the hero on a low-end Android phone with data saver\non.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-prefers-reduced-data.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f48d3399-aa20-4c37-a780-81ebeda7141b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-pwa-manifest-dynamic","type":"changed","scope":"marketing","summary":"Phase 26 — PWA manifest dynamic per deployment + adds app-shortcut actions.","body":"Before: `/public/manifest.webmanifest` hard-coded\n`\"name\": \"Helios — AI-native Work OS\"`, `\"short_name\": \"Helios\"`,\nand a Helios-specific description. Install-to-home-screen on a\nwhite-label deployment still showed \"Helios\" as the app name on\nthe iOS / Android home grid — a public brand leak the moment a\nvisitor installed the PWA.\n\nAfter: `/src/pages/manifest.webmanifest.ts` is an Astro SSR endpoint\nthat reads `loadBranding({ request })` and emits the operator's:\n\n- `name` = `branding.appName || 'Platform'`\n- `short_name` = first whitespace-separated token of `appName`\n- `description` = `branding.appDescription` (with sensible fallback)\n- Primary icon = `branding.faviconUrl || '/favicon.svg'`\n\nPlus the manifest now ships `shortcuts[]` for the four most-trafficked\nsurfaces — `/login`, `/pricing`, `/changelog`, `/status`. On Android,\nthese surface as long-press menu items on the home-screen icon; on\niOS-PWA they show up in the app's quick actions context menu.\n\n`Cache-Control: public, max-age=3600`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-pwa-manifest-dynamic.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"bbe109d6-6c13-4164-8f09-2e700414b6c6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-radix-animations","type":"fixed","scope":"marketing","summary":"Phase 32 — Mega menu + mobile drawer + dialog overlay now actually animate (tailwindcss-animate utilities re-implemented in globals.css).","body":"Third silent-Tailwind-drop bug caught + fixed in this batch.\n\nThe marketing JSX uses the shadcn/tailwindcss-animate utility\npattern for every Radix overlay / dropdown / dialog:\n\n```jsx\n<Dialog.Overlay className=\"data-[state=open]:animate-in data-[state=closed]:animate-out\" />\n<NavigationMenu.Viewport className=\"data-[state=closed]:animate-out data-[state=open]:animate-in\" />\n```\n\nBut `tailwindcss-animate` was never added as a devDependency. So\n`.animate-in` and `.animate-out` weren't defined anywhere — Tailwind\nsilently dropped them. The desktop mega menu, mobile nav drawer, and\nbackdrop overlay all opened/closed with **zero motion**.\n\nRather than pull in the plugin and grow the dep tree, hand-wrote the\nmatching utilities directly in `globals.css`:\n\n- `.animate-in` + `.animate-out` — keyframe drivers, both shapes\n  read CSS custom properties (`--enter-translate-x`,\n  `--enter-scale`, `--enter-opacity`, etc.) so a single keyframe\n  serves every shape variant.\n- Shape modifiers that match the plugin's API: `fade-in-0`,\n  `fade-out-0`, `zoom-in-95`, `zoom-out-95`,\n  `slide-in-from-top-2`, `slide-out-to-top-2`,\n  `slide-in-from-right`, `slide-out-to-right`,\n  `slide-in-from-left`, `slide-out-to-left`.\n\nJSX call sites composed:\n\n- Mobile-nav drawer (`Dialog.Content`):\n  `data-[state=open]:slide-in-from-right` /\n  `data-[state=closed]:slide-out-to-right` — drawer slides in/out.\n- Mobile-nav overlay (`Dialog.Overlay`):\n  `fade-in-0` / `fade-out-0` — backdrop fades.\n- Mega-menu viewport (`NavigationMenu.Viewport`):\n  `fade-in-0` + `zoom-in-95` + `slide-in-from-top-2` — drops\n  gently with a subtle zoom.\n- Mega-menu content motion (Radix's `data-motion=from-start/end`):\n  fade + slide in the direction the user is moving between columns,\n  so the panel swaps without a jarring teleport.\n\nAll animations are no-op under `prefers-reduced-motion: reduce`.\nConfirmed `.animate-in`, `.animate-out`, `.fade-in-0`,\n`.slide-in-from-right`, `.slide-in-from-top-2`, `.zoom-in-95` are\nnow in the generated CSS bundle.\n\nVisual reach: every menu open/close on the marketing site now feels\nintentional instead of teleporting into place.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-radix-animations.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"9d9a2696-c1a7-4027-b640-3c14e00b22fc","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-rel-me-verification","type":"changed","scope":"marketing","summary":"Phase 23 — emit <link rel=\"me\"> for every configured social profile (IndieWeb verification).","body":"The IndieWeb's rel-me protocol is how decentralized identity\nverifiers (Mastodon's \"Verified profile\" badge, Bluesky's\ndomain-verification flow, brid.gy bridges) confirm that a site\nbelongs to the same operator as a given social account: both ends\nlink to each other, the rel-me chain is reciprocal, the verifier\ntrusts the relationship.\n\nUntil now, the marketing site never advertised its social links via\nrel-me — only via the human-readable footer. Mastodon servers\nvisiting `heliosworks.com` couldn't verify the back-link from\n`@heliosworks@…` even when the operator had configured it.\n\nThis pass: BaseLayout walks `branding.socialLinks` (Twitter,\nMastodon, Bluesky, LinkedIn, GitHub — every entry that\n`platform_settings.app_social_*` populates) and emits one\n`<link rel=\"me\" href=\"…\">` per non-empty value. Renders in the\n`<head>` of every public page.\n\nNo content change visible to humans — search engines + IndieWeb\nverifiers pick it up automatically. Operators who set their\nprofiles in Settings → Branding → Social now get the verification\nbadge as soon as they add the reciprocal back-link on the\nplatform side.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-rel-me-verification.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c6b0dce3-eedb-4bee-b20d-ea91f646fcd6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-rss-alternate-links","type":"changed","scope":"marketing","summary":"Phase 22 — every page advertises the changelog + status + blog RSS feeds via <link rel=\"alternate\">.","body":"Before: the changelog + status + blog RSS feeds existed but only\nlinked-to inline on their own pages.\n`<link rel=\"alternate\" type=\"application/rss+xml\">` is the proper\nSEO + reader-discovery mechanism — RSS readers (NetNewsWire,\nFeedbin, Inoreader, Reeder) auto-detect feeds via this header on\nANY page on the site, not just the index.\n\nThree `<link rel=\"alternate\">` tags added to BaseLayout, so every\npublic page exposes:\n\n- `/api/changelog.rss` (titled \"Changelog\")\n- `/api/status.rss` (titled \"Status\")\n- `/rss.xml` (titled \"Blog\")\n\nReaders that visit the home page (or any other page) can now\nsubscribe to whichever feed they want without first navigating to\nthe relevant section.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-rss-alternate-links.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"016cea26-db99-49f8-a671-c70a8152d200","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-scroll-aware-nav","type":"changed","scope":"marketing","summary":"Phase 30 — scroll-aware top nav (subtle elevation shadow once scrolled) + scroll-progress visibility fix.","body":"Three small refinements on top of the z-index regression fix:\n\n1. **Scroll-aware top nav.** The marketing header now sets\n   `data-scrolled=\"true\"` once the visitor has scrolled past 8px and\n   clears it on the way back up. A `data-[scrolled=true]:` Tailwind\n   variant gives it a subtle 24px elevation shadow + a denser border\n   alpha when the flag is on. Visually telegraphs \"the nav is now\n   floating over content\" — and pairs with the just-fixed z-index so\n   the elevation is actually visible.\n\n2. **Scroll-progress bar visibility.** The reading-progress line at\n   the top of the viewport was sitting at `z-index: 60`, which after\n   the nav z-index fix put it *under* the now-correct sticky header.\n   Bumped to `z-index: 101` (just above `--z-sticky` = 100). The inline\n   comment already documented the intended behaviour (\"Sits above the\n   sticky header\"); the value was the regression.\n\n3. **Footer doc accuracy.** The footer's JSDoc claimed \"native\n   `<details>` accordion on mobile so we don't need JS\" — but the\n   markup is a plain responsive grid (`grid-cols-2` → `md:grid-cols-4`\n   → `lg:grid-cols-8`). Rewrote the comment to match what's actually\n   there.\n\nAll three behaviours: rAF-throttled, no extra layout shift, no-op\nunder prefers-reduced-motion (the shadow uses an instant transition\nduration when reduced-motion is set).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-scroll-aware-nav.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d02dbe63-68e5-4dc3-bb4b-f701dd96adf6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-search-palette-polish","type":"changed","scope":"marketing","summary":"Phase 33 — Search palette gains enter animation + body scroll lock (matches Radix Dialog defaults).","body":"Two refinements riding on the freshly-restored animation utilities:\n\n1. **Enter animation.** The search-palette dialog used a plain\n   `{open && (<div>…</div>)}` mount, so it appeared instantly with\n   no motion — felt teleported in. Composed\n   `animate-in fade-in-0` on the backdrop and\n   `animate-in fade-in-0 zoom-in-95 slide-in-from-top-2` on the inner\n   panel so the palette drops in over ~220ms with a gentle 5% zoom.\n\n2. **Body scroll lock.** Radix Dialog locks `document.body.overflow`\n   while the dialog is open; the search palette is a custom mount\n   that wasn't doing this. Visitors could scroll the underlying page\n   while the palette was dispatching keys, which jumped layout on\n   `Enter` navigation. Mirrored the Radix behaviour via a `useEffect`\n   that swaps `document.body.style.overflow = 'hidden'` while open and\n   restores the previous value on close.\n\nBoth behaviours respect `prefers-reduced-motion: reduce` (the\n`.animate-in` utilities defined in Phase 32 already do).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-search-palette-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5dc7b6ae-7ace-49c9-9559-dad8f75c497b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-search-palette-uplift","type":"changed","scope":"marketing","summary":"Phase 31 — OpenSearch ?q= deep-link + ↑↓/Enter keyboard navigation in the marketing search palette.","body":"Two senior-grade gaps in the marketing site's command palette, fixed:\n\n### 1. OpenSearch `?q=` was a dead-end\n\nPhase 27 added `/opensearch.xml` and the `<link rel=\"search\">` in the\n`<head>` so browsers (Chrome Omnibox, Safari sidebar, Firefox bar)\ncan register the site as an address-bar search engine. The descriptor\npoints the browser at `/?q={searchTerms}`. Until now, hitting that\nURL just landed the visitor on `/` with `?q=pricing` sitting in the\naddress bar and **nothing else happened** — the palette never saw it.\n\n`SearchPalette` now reads `window.location.search` on mount: if `q`\nis present and non-empty, it pops the palette open, seeds the input,\nand scrubs the param from history (`replaceState`) so a back-button\npress doesn't re-open the palette on the previous page.\n\nOpenSearch deep-links work end-to-end now.\n\n### 2. No keyboard navigation through results\n\nCmd-palettes are expected to support ↑/↓/Enter/Home/End — the\nprevious implementation forced visitors to click. Added:\n\n- `useState<number>(activeIdx)` tracks the selected result.\n- The `<input>` consumes ↑/↓/Home/End to move the cursor, and Enter\n  to navigate to the active result's URL.\n- `aria-autocomplete=\"list\"`, `aria-controls`, and\n  `aria-activedescendant` wire the input to the listbox so screen\n  readers announce the focused option as you arrow through.\n- Active row gets `aria-selected=\"true\"` + a `bg-surface` highlight;\n  the active element auto-scrolls into view when arrowed off-screen.\n- Hovering a row also updates `activeIdx` so mouse + keyboard share\n  state (no dueling cursors).\n- Footer hint expanded: `↑↓ navigate · ↵ open · Esc close`.\n\n### Footnote — Astro parser landmine fixed\n\nThe `<noscript><style>{` `.reveal-stagger>*{...}`}` `</style></noscript>` block\nwas a parsing trap: Astro's HTML/JSX hybrid parser was reading the\n`>*{` inside the template literal as the opening of a JSX expression,\nwhich caused **the entire rest of the `<head>` block** to be parsed\nas one giant unterminated expression. The only reason it wasn't\ncaught earlier is that `astro check` was failing the typecheck step\nbut not failing the `dev build` (Astro can still emit valid HTML\nthrough its raw-output path).\n\nComposed the CSS string in frontmatter (`const noJsRevealCss = …;`)\nand injected via `<noscript set:html={…}></noscript>` which skips\nJSX parsing of the inner content entirely. Typecheck now clean.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-search-palette-uplift.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5856a291-29ca-4381-b7e2-1c811a7dd50a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-security-humans-txt","type":"changed","scope":"marketing","summary":"Phase 25 — /.well-known/security.txt (RFC 9116) + /humans.txt with branding-aware content.","body":"Two small but standards-compliant additions to the site root:\n\n- **`/.well-known/security.txt`** (RFC 9116) — security researchers\n  use this to find the right contact for vulnerability disclosure\n  before they file a public CVE. Emits the operator's\n  `branding.supportEmail`, the operator's `/contact?topic=security`\n  URL, an `Expires` date one year out (refreshed automatically by\n  the daily cache), `Preferred-Languages: en`, a `Canonical` URL,\n  and `Policy:` / `Acknowledgments:` URLs pointing at\n  `/security#vulnerability-disclosure` and `/security#hall-of-fame`.\n- **`/humans.txt`** (humanstxt.org convention) — a plain-text credit\n  page. Lists the team (from `branding.companyName`), the support\n  contact, and the open-source ecosystem the site ships on (Astro,\n  React, TanStack, Cloudflare, Postgres, Drizzle, pgvector, Better-\n  Auth, Inngest, Tailwind, Lucide, simple-icons, Geist, Anthropic /\n  OpenAI / Google AI).\n\nBoth are SSR endpoints, both honor white-label branding, both cache\n`public, max-age=86400`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-security-humans-txt.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"695cc39a-9103-4b7a-bb89-c960492d68ed","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-hardening-phase-e","type":"changed","scope":"web","summary":"Signup hardening Phase E — paid-path orgId forwarding, setActive sync, direct-landing recovery, ownership-gate regression test, rate-limit for readiness probe.","body":"Phase E of the signup hardening plan. Closes the items the original\nplan deferred plus adds a regression test for the SEC-1 fix.\n\n**E.1 — Paid-path `orgId` forwarded from `/pay/return`.** The\n`payments.session.confirm` action now returns the backfilled\nsession `orgId` (populated by the `saas-on-payments-session-confirmed`\nsubscriber). `/pay/return` threads it into the redirect URL as\n`?orgId=<id>&fromSession=1`. `CompleteStep` reads it from search\nparams and passes it as the readiness probe's `orgId` input override\nso the first poll already targets the right org instead of returning\nthe pre-create placeholder for one poll cycle while the actor's\nsession cookie catches up. The readiness ownership gate\nrevalidates the actor's membership before reading anything, so\nhostile orgId hints just see the placeholder.\n\n**E.2 — Paid-path Better-Auth `setActive` sync.** After /pay/return\nredirects to CompleteStep, the actor's session cookie still has\n`activeOrganizationId: null` (the saas subscriber created the\nmembership server-side but never touched the cookie). Subsequent\nrequests resolve through `resolveActiveMembership`'s ranked\nfallback (works for /api/me) but any client-side code reading\n`session.activeOrganizationId` sees null. CompleteStep now fires\n`authClient.organization.setActive` once the readiness probe\nconfirms BOTH `workspace` and `membership` rows are done, so the\ncookie syncs to the new org. Ref-guarded to fire once per mount.\n\n**E.3 — Direct-landing recovery.** When a user navigates to\n`/signup?step=complete` cold (refresh, bookmark, share link, or\npaid-path with an expired session) they used to see the\nprocessing spinner for 25 s then a silent redirect to `/`. Now\nafter 4 s of no progress AND no draft AND no orgId hint, a\nrecovery card appears: \"Nothing to finalise here yet\" with\n\"Start signup\" and \"Open dashboard\" CTAs. Suppressed the moment\nany real readiness data arrives, so the legitimate slow-seed\ncase isn't disrupted.\n\n**E.4 — SEC-1 regression test.** Added\n`signup-readiness.test.ts` with 6 cases covering the placeholder\nbranches, the ownership gate (foreign-org probe returns the\nSAME shape so no enumeration), the A.2 zero-module fix, and the\nB.3 revenue-module gating of `payments_routing`. The\ncritical assertion is that the foreign-org response is\nstructurally indistinguishable from the no-context placeholder\nso a probe can't tell \"real org I'm not a member of\" from \"org\ndoesn't exist yet\". Re-introducing the SEC-1 disclosure would\nbreak this test.\n\n**E.5 — Rate-limit `signup_readiness`.** Added a per-(IP, action)\nrule capping the readiness probe at 240/min. Wide enough for two\nconcurrent tabs + StrictMode double-fire + the post-redirect\npaid path racing the first poll; tight enough to choke a runaway\nclient or scripted attempt at the edge. The action's per-call\ncost (~7 indexed queries) means a polling DOS without this cap\namplifies fast.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:58:24.116Z","updatedAt":"2026-06-13T08:58:24.116Z"},{"id":"4ab43007-941e-4dc9-9547-d1d27ca48a01","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-sitemap-quality","type":"changed","scope":"marketing","summary":"Phase 24 — sitemap filter excludes auth/preview/OG/feed routes + per-page priority and changefreq hints.","body":"The Astro sitemap was emitting every page including redirect-only\n`signup`/`login`, internal `/preview/` routes, the `/og/` image\nendpoints, and feed XML routes. Crawlers wasted budget on URLs that\neither bounce or shouldn't be indexed.\n\nThis pass tightens the filter + adds per-page priority + changefreq\nhints crawlers can use to plan their crawl:\n\n- **Filter expansion:** drops `/preview/`, `/og/`, `/signup`, `/login`,\n  and `/robots.txt`.\n- **Priority 1.0, weekly:** home (per locale).\n- **Priority 0.9, weekly:** core conversion pages — `/pricing`,\n  `/product`, `/ai`, `/partners`.\n- **Priority 0.7, monthly:** deep index pages — `/product/<slug>`,\n  `/integrations/<slug>`, `/compare/<vendor>`, `/solutions/<persona>`.\n- **Priority 0.6, weekly:** fresh content — `/blog/<slug>`,\n  `/changelog/<release>`.\n- **Priority 0.4, monthly:** stable legal pages — `/privacy`,\n  `/terms`, `/security`, `/compliance`, `/dpa`, `/hipaa`,\n  `/sub-processors`, `/trust`.\n- **Default:** 0.6, monthly.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-sitemap-quality.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"56b7d333-1137-446c-85ff-a9c7b3075924","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-status-color-shades","type":"fixed","scope":"marketing","summary":"Status-color shade classes (text-success-500, bg-warning-500/15, etc.) now actually render — they were silently dropped by Tailwind.","body":"The marketing JSX freely uses two flavours of status colour:\n\n- Flat: `text-success`, `bg-warning/15`, `border-danger`\n- Shade-suffixed: `text-success-500`, `bg-warning-500/15`, `text-warning-600`\n\nBut `tailwind.config.ts` only declared the flat tokens:\n\n```ts\nsuccess: 'hsl(var(--color-success) / <alpha-value>)',\nwarning: 'hsl(var(--color-warning) / <alpha-value>)',\ndanger:  'hsl(var(--color-danger)  / <alpha-value>)',\n```\n\nWithout explicit shade keys, Tailwind silently drops `*-500` /\n`*-600` classes — they're treated as unknown. ~30 call sites across\nstatus pages, code-tab syntax highlights, the theme-toggle sun icon,\ntrust strips, broken-stack visuals, competitive matrices, pricing\ncomparison bars, callers/action JSON examples, and forms were all\nfalling back to `text-fg` (the foreground colour) instead of the\namber/green/red they were styled for.\n\nSame class of bug as the recently-fixed `z-sticky` regression: an\nunknown Tailwind class that looks correct in source but compiles to\nnothing.\n\nFix: gave `success`, `warning`, `danger` the\n`{DEFAULT, 500, 600}` shape so every existing call site renders with\nthe intended HSL. The shades resolve to the same value as DEFAULT\n(the design surface is conceptually a flat token), but the explicit\nkeys make Tailwind emit `.text-success-500 { color: hsl(...) }`\ninto the bundle.\n\nNo visual change for flat-token call sites; every shade-suffixed\ncall site now paints correctly for the first time.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-status-color-shades.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"6a8fc1ec-b13b-4bcc-ac25-2c97a1013eef","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-status-css-var-aliases","type":"fixed","scope":"marketing","summary":"Success/warning/danger -500/-600 CSS variables now alias the flat tokens so shadow glows + keyframes paint.","body":"Companion to the Tailwind shade-class fix. JSX also uses\nshade-suffixed CSS variables inside arbitrary-value Tailwind classes\nand inline styles, e.g.:\n\n```jsx\nclassName=\"shadow-[0_22px_55px_-30px_hsl(var(--color-success-500)/0.50)]\"\nstyle={{ background: 'radial-gradient(circle, hsl(var(--color-success-500) / 0.18), transparent 70%)' }}\n```\n\n…and `globals.css` has a `@keyframes successPulse` running off\n`hsl(var(--color-success-500) / 0.55)`.\n\nBut `tokens.css` only declared `--color-success` (no `-500` /\n`-600`). So `hsl(var(--color-success-500) / 0.50)` evaluated to\n`hsl( / 0.50)` — invalid CSS — and the shadow / keyframe glow\nsilently rendered nothing.\n\nFixed by aliasing the shade variables to the flat ones in tokens.css:\n\n```css\n--color-success-500: var(--color-success);\n--color-success-600: var(--color-success);\n--color-warning-500: var(--color-warning);\n--color-warning-600: var(--color-warning);\n--color-danger-500:  var(--color-danger);\n--color-danger-600:  var(--color-danger);\n```\n\nThe shadow glow on the contact-form / newsletter-signup /\nstatus-subscribe-form success states now actually paints. The\nhero pulse keyframe also fires correctly.\n\nVisual reach: same ~10 surfaces affected by yesterday's Tailwind\nshade fix, but on the box-shadow + animation layer.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-marketing-status-css-var-aliases.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"0e2efad3-2d10-4af5-876a-dc5d69b8fb20","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"onboarding-phase-g-completeness","type":"changed","scope":"web","summary":"Dashboard onboarding widget now reads the unified catalog; invited members get an auto-created HRM employee row; Settings → Onboarding link added; email-change broadcasts.","body":"Phase-G follow-on to the A-F onboarding overhaul:\n\n**SecurityOnboardingWidget reads the unified state.** It was a\nparallel state machine hard-coded to three steps (verify email,\n2FA, passkey) reading from `useMe()` directly. Now it queries\n`iam.user.onboarding.get` so catalog overrides, locale-step, and\nthe auto-detection + persistence pipeline from earlier tiers\nflow through here too. Cross-tab broadcasts that invalidate the\nonboarding query refresh this widget alongside `/onboarding`.\nPasskey survives as a soft pseudo-step appended at the end —\nnot gating, just a strong recommendation.\n\n**Invited members get an HRM employee row.** The\n`iam.invitation.accepted` subscriber used to back-link an\nexisting `hrm_employees` row to the freshly-minted user but did\nnothing when no row existed (plain Settings → Users invites).\nResult: invited members were invisible to HRM until an admin\nmanually created their row. Now the subscriber auto-creates a\nstarter employee row when none exists — `EMP-NNNN` numbered\nfrom the org's current max, name split from `users.name`,\n`workEmail` from `users.email`, full-time / active / office.\nIdempotent — re-firing for a user who already has a row no-ops.\nWelcome notification mirrors the existing linked-employee path.\n\n**Settings → Account → Onboarding link.** Users who dismissed the\ndashboard widget needed a way back to the personal checklist.\nAdded as a new entry under Settings → Account between\nNotifications and My data; points at the existing `/onboarding`\nroute.\n\n**Email-change broadcasts `profile`.** Saving a new email via\nSettings → Profile triggered the Better-Auth confirmation flow\nbut didn't broadcast cross-tab — other tabs reading `useMe()`\nor the verification pill kept showing the old address until\ntheir staleTime expired. Now fires `broadcastAuthChange('profile')`\nwhich invalidates both `['me']` and the onboarding query.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-onboarding-phase-g-completeness.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"8309396f-f06e-4859-856a-772356de7c8f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"onboarding-phase-h-realtime-verify","type":"fixed","scope":"web","summary":"Email verification flips the dashboard banner + onboarding checklist in real time — Better-Auth fires a server-side hook that persists verify_email, and /verify-email broadcasts cross-tab.","body":"Three Phase-H fixes that close the email-verification loop:\n\n**Server-side persistence.** Added a new\n`afterUserEmailVerified` hook to the auth wrapper that fires once\nwhen `users.email_verified` flips false → true (detected via the\nexisting `user.update.before/after` paired hook pattern, same\nshape as the email-changed + 2FA-toggle dispatches). The web\nhost's implementation calls\n`iam.user.onboarding.complete_step('verify_email')` with a\nsystem context — so the step's `completedAt` lands in the DB at\nthe moment Better-Auth verifies the token, not deferred until\nthe user's next dashboard mount fires Tier B's read-side write.\n\n**Client-side cache + broadcast on success.** The `/verify-email`\nroute's success state now invalidates `['me']` and the onboarding\nquery AND fires `broadcastAuthChange('profile')`. Without this,\nthe 2-second auto-bounce to `/` landed on a dashboard reading\n`emailVerified: false` from the cached `me` payload. Now every\ntab on the browser sees the flip immediately.\n\n**Signup wizard reads /me, not the session cookie cache.** The\n`<VerifyInboxNotice>` strip surfaced across the workspace /\nmodules / invite steps was using `useSession()` — Better-Auth's\nsession has a 60s cookie cache, so a verification done in another\ntab while the user was still on the wizard wouldn't hide the\nnotice for up to a minute. Switched to `useMe()` which reads\nlive DB truth and is on the broadcast-invalidation chain. The\nnotice now disappears the instant verification lands, even with\nthe wizard still open.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-onboarding-phase-h-realtime-verify.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"478331c9-f144-481e-92cb-5f6f314a86bd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"onboarding-tier-d-settings-modules","type":"added","scope":"web","summary":"New /settings/modules page lets owners + admins toggle which product modules appear in the workspace's nav rail.","body":"The signup wizard's \"Modules to enable\" step had no follow-up\nsurface: a workspace that opted into Sales-only could never turn\nHRM back on without an engineer touching the DB. The signup copy\nitself promised \"you can change this later from Settings → Modules\"\nbut the page didn't exist.\n\nIt exists now. `/settings/modules` lists every entry in the\n`MODULE_CATALOG`:\n\n- Locked modules (Dashboard, Activity, Settings) have a disabled\n  switch + \"Always on\" chip.\n- Selectable modules show a `<Switch>` driven by\n  `iam.workspace.preferences.get`; flipping calls\n  `iam.workspace.preferences.upsert` and the AppSidebar refreshes\n  cross-tab.\n- Plan-locked modules render with a `tone=\"warning\"` \"Upgrade to\n  use\" chip on top of the standard toggle so admins see the\n  upgrade path without leaving the page.\n- Beta / Coming-soon modules are clearly badged.\n\nWired into the Settings → Workspace sub-nav between Billing and\nCurrencies so it's discoverable next to other workspace-wide\nconfiguration. Permission gate is the same one the upsert action\nuses (`iam:organization:update`) — same population as can edit\nthe org profile.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-onboarding-tier-d-settings-modules.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"21aa37d2-13ed-422a-adb5-40dc1e6dcf05","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"onboarding-tier-e-sync-hardening","type":"fixed","scope":"web","summary":"Profile + 2FA broadcasts now also invalidate the onboarding query, so the dashboard checklist updates the moment auto-derived steps complete.","body":"Profile saves and 2FA enrolment / disablement already broadcast\n`'profile'` and `'two-factor'` cross-tab. The broadcast handlers\ninvalidated `['me']` so the sidebar avatar / topbar greeting\nrefreshed, but they didn't touch\n`['iam.user.onboarding.get', 'self']`. Consequence: the user saves\ntheir profile or enrols a passkey, the dashboard onboarding widget\nstill shows the corresponding step as pending until the user\nrefreshes or the query's staleTime expires.\n\n`QUERY_KEYS_BY_KIND['profile']` and\n`QUERY_KEYS_BY_KIND['two-factor']` now both include the onboarding\nquery alongside `['me']`. Combined with Tier B (which persists\nthe auto-overlay back to the DB on every read), the chain now\nruns end-to-end:\n\n  user enrols 2FA\n  → broadcastAuthChange('two-factor')\n  → onboarding query invalidates\n  → next getOnboardingState reads twoFactorEnabled=true\n  → overlay stamps enable_two_factor\n  → DB persists the completion\n  → response includes the new state\n  → widget shows ✓\n  → if all required steps done, isComplete flips and the banner\n    disappears\n\nAll in one tab transition, no refresh required.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-onboarding-tier-e-sync-hardening.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ce74bda4-27e1-41fc-a7f7-27567e1a7985","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"onboarding-tier-f-ui-polish","type":"changed","scope":"web","summary":"/onboarding completion card gets a layered celebration animation; each completed step shows its auto-detection source; org-setup modules step now links to /settings/modules.","body":"Three polishes once auto-detection + sync work:\n\n**Celebration on completion.** The \"you're all set up\" card was a\nflat `<Card>` with a duotone check icon. Now it lands with a\nlayered halo (matching the signup wizard's CompleteStep), a\nspring-animated CheckCircle at 14×14, and a centered single-CTA\nlayout — the moment feels earned instead of a quiet\n\"you got redirected here.\"\n\n**Provenance pill on completed steps.** Each completed onboarding\nstep now shows how it was satisfied:\n- `verify_email` → \"via email verification\" when\n  `users.email_verified` is true.\n- `enable_two_factor` → \"via 2FA enrolment\" when\n  `users.two_factor_enabled` is true.\n- `complete_profile` → \"via profile save\" when the user has both\n  name and image.\n- `set_locale_preferences` → \"via locale + timezone\" when both\n  are set.\n\nThe pill is pure-UI inference from the underlying `users.*`\nsignals (no schema change), so the user understands the system\nnoticed their work instead of wondering why a step they didn't\nclick on is suddenly green.\n\n**Org-setup `modules` step points at /settings/modules.** The\ncatalog href used to point at `/setup/modules` (the wizard-only\nsurface). Now the canonical destination is the new Settings →\nModules page, so the dashboard banner's \"Choose modules\" CTA\ntakes the owner straight to the ongoing-config surface instead\nof replaying the first-time wizard.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-onboarding-tier-f-ui-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c019e8b7-36dc-443a-9c10-2c26e2274468","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"passwordless-inner-rhythm","type":"changed","scope":"web","summary":"Inside the unified Passwordless card, OTP verify + magic-link sent state drop the bordered card-in-card and pick up a matching footer (Use different email / Resend).","body":"Follow-up polish after the Passwordless wrapper landed. The OTP\nverify step and the magic-link \"sent\" state each had their own\nbordered sub-card with an accent halo, intended to feel like a\nsibling pair when they sat as two stacked blocks. Now that they\nlive INSIDE a card (the Passwordless wrapper itself), that\nsub-card chrome was redundant — a card inside a card, two borders\nfighting for the same edge.\n\nBoth inner surfaces collapse to a tighter inline status strip:\n\n- Same 7×7 circular accent halo with `EnvelopeOpen`.\n- One-line \"{Code|Link} sent to {email}\" with the address in\n  font-mono.\n- A small monospace TTL pill on the right (\"5m\" / \"10m\") so the\n  expiry is visible at a glance without an extra paragraph.\n\nThe magic-link \"sent\" state additionally:\n\n- Picks up the same `Use a different email` / `Resend link` split\n  footer that OTP's verify step has, with a 30-second cooldown\n  shared between the two methods.\n- Keeps a single explanatory line (\"Open the email and tap the\n  sign-in button…\") inside a muted background — it's a terminal\n  wait-state so it deserves a tiny bit more body than the OTP\n  step (which has the segmented input below it as the focal\n  point).\n\nSwitching tabs inside the Passwordless card is now a content\nswap, not a layout shift — both methods share the same vertical\nrhythm under the wrapper.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-passwordless-inner-rhythm.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"db2ff513-2afd-4878-a6d1-09eac9727e00","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"passwordless-shared-email","type":"changed","scope":"web","summary":"Email address persists across the Code / Link tab switch inside the Passwordless card — no more retyping when comparing methods.","body":"The Passwordless card unmounts the inactive method when the user\ntoggles tabs (AnimatePresence + `mode=\"wait\"`). That meant an\nemail typed into the Code tab was lost the moment the user\nclicked Link to compare delivery options — they'd have to retype.\n\nNow the wrapper holds a shared email state and passes it down as\na controlled prop to both `EmailOtpSignIn` and `MagicLinkSignIn`.\nEach method accepts the email controlled-optional — when the\nwrapper provides it, the input is parent-controlled; standalone\nusage falls back to internal state. Switching tabs is now a pure\ncontent swap with no input churn.\n\nThe per-method ephemeral state (verify step's OTP digits, sent\nstate's \"we sent it\" status) intentionally still resets on tab\nswitch — a method switch is an explicit \"I want to try the\nother path\" intent, so starting fresh in the new method is the\nright behaviour.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-passwordless-shared-email.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"05008d7a-b85d-48db-a436-56914e9933bc","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"passwordless-unified-card","type":"changed","scope":"web","summary":"Email-OTP and magic-link sign-in unify under a single Passwordless card with a method selector.","body":"Email-OTP and magic-link were previously two separate stacked\nblocks in the \"Other sign-in methods\" section, each with its own\nintro paragraph reading \"Get a code by email…\" / \"Email me a\nlink…\" — three blocks of similar copy, no visual grouping, no\nshared chrome.\n\nNow they live under a single `<PasswordlessSignIn>` card:\n\n- ShieldCheck halo + \"Passwordless sign-in\" title up top.\n- `<Segmented>` picker between Code / Link (with Key / Envelope\n  icons) when both methods are enabled.\n- `AnimatePresence` swap between the two method bodies — slide\n  up / down so switching tabs feels intentional rather than\n  abrupt.\n- Single intro line that adapts to which method is active (or\n  whether both are enabled — \"pick how you want us to deliver\n  the proof\").\n\nWhen only one of the two methods is configured on a deployment,\nthe picker is hidden and the card collapses to just that one\nmethod's flow. LDAP stays separate because directory creds are\na fundamentally different input shape (username + password).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-passwordless-unified-card.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e30b8117-806f-4e2d-965d-612b454777f7","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"quotation-public-accept-persists","type":"fixed","scope":"sales","summary":"Clicking Accept (or Decline) on a public quotation now actually transitions the quotation status — not just records the click.","body":"Reported by staging customer: recipient clicks Accept on `/q/<id>?token=…`, the activity timeline records \"Recipient clicked Accept\", but the quotation stays in `sent` state on the operator side. Sales reps couldn't tell from the detail page which proposals had actually been accepted.\n\nRoot cause: `sales.quotation.public.acknowledge` only wrote an activity row and deferred the actual status change to \"the authenticated accept/decline actions\" — but recipients never authenticate, so the transition never happened.\n\nFix: the public-acknowledge action now persists the status transition itself when the action is `accept_clicked` or `decline_clicked`. The token's validity (already verified above the activity insert) is treated as the auth signal — possession of an active share token is sufficient authorisation to mark the quotation accepted on the recipient's behalf.\n\n- Only transitions from still-actionable states (`draft` / `sent` / `viewed`). Already-accepted / declined / expired / cancelled quotations keep their existing status; the click still records to the activity feed so the audit trail captures duplicate interactions.\n- Emits the existing `sales.quotation.accepted` / `sales.quotation.declined` domain events so downstream subscribers (deal-stage advance, owner notification, accounting hook) fire identically whether the transition came from the operator or the recipient.\n- Decline path forwards the recipient's free-text note into the quotation's `notes` column (in addition to the activity-row metadata) so the operator sees the customer's reason directly on the detail page.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-quotation-public-accept-persists.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2d6c44d0-0677-4a49-b2bf-be4eefaccfc4","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"quotation-public-hooks-order","type":"fixed","scope":"sales","summary":"Public quotation share link no longer crashes with React error #310 on first load.","body":"Reported by staging customer: clicking the \"View quotation\" button in a customer-facing email landed on `/q/<id>?token=…` and immediately threw:\n\n> Minified React error #310 — Rendered more hooks than during the previous render\n\nRefreshing or hitting Retry rendered the quotation correctly. Root cause was a hook-ordering bug: `useFormatter(q.currency)` was called AFTER three early returns (missing-token / loading / error). On the first render the page was loading so the second early return fired and the formatter hook never ran; once the data arrived the next render reached the call site and ran one extra hook, which React detects as a count mismatch and aborts the tree.\n\nFix: hoist `useFormatter` above every early return, with a `'USD'` fallback while `quotation.data` is undefined. The formatter is only consumed by the data-loaded branch so the fallback is never displayed.\n\nAudited the sibling public surfaces — `/i/<id>` (invoice) and `/portal/<companyId>` (customer portal) — and both already do the right thing. This was isolated to the quotation route.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-quotation-public-hooks-order.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f794139b-1bce-4503-9991-13c5b57d927d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"recent-accounts-method-aware","type":"fixed","scope":"web","summary":"Tapping a remembered-account row now triggers the saved sign-in method (passkey / OAuth / SAML / magic-link) instead of just pre-filling the email.","body":"`RecentAccountsPicker` was already showing each saved account's\nlast method (PASSKEY / GOOGLE / MAGIC LINK / etc.) but tapping a\nrow only ever did one thing: pre-fill the email field and focus\nthe password input. For users whose last successful sign-in was\npasswordless, that meant the picker promised \"Continue as you\"\nand then forced them through password entry anyway.\n\nNow `onPick` routes through a method-aware `continueAs` helper:\n\n  - **passkey** → fires `authClient.signIn.passkey()` directly,\n    surfacing the WebAuthn dialog with the user's saved credential.\n    On NotAllowedError (user cancels the dialog), silently falls\n    back to focusing the password field.\n  - **OAuth (google / github / microsoft / apple / discord)** →\n    drops a `markPendingSignIn` claim then redirects to the IdP.\n    The post-auth claim hook captures the email into memory on\n    return.\n  - **SAML** → claim + redirect to `/api/auth/saml/login`.\n  - **magic-link** → sends a fresh link to the remembered email\n    + surfaces a \"Sign-in link sent to alex@acme.com\" toast.\n  - **password / ldap / unknown** → original behaviour: pre-fill\n    email + focus password.\n\nThe clicked row shows an inline spinner + \"Continuing as you via\npasskey…\" status during the in-flight flow; sibling rows are\ndisabled to prevent concurrent sign-in attempts.\n\nAlso: auth layout columns now scroll independently. The dark\nbranded aside stays pinned in place while the form column (which\ncan get long — recent accounts + passkey + OAuth + SAML +\ndivider + password + lockout banner + magic-link + email-OTP +\nLDAP) scrolls within itself. Previously the whole page scrolled\nwhich pushed the branded marketing copy out of the viewport.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-recent-accounts-method-aware.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"54de7906-5834-49dc-bb39-c59520eed3b5","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"chat-files-engagement-popovers","type":"changed","scope":"chat","summary":"Chat files + engagement popovers — skeleton rows instead of \"Loading…\" text.","body":"Two more chat popovers polished:\n\n- **Channel files popover** — 4 file-card skeletons (icon + name + size) instead of \"Loading…\" sentence.\n- **Channel engagement popover** — 3 stat-card skeletons + 3 row skeletons (avatar + name + count) mirroring the loaded structure, so the popover doesn't reflow when stats land.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-chat-files-engagement-popovers.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"88e58adb-4229-4150-b3c3-7cb04a7e12d8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-bugfix-and-multicurrency","type":"fixed","scope":"sales","summary":"Quotation share link 500, send-email validation, and multi-currency display across the sales surface.","body":"- Fixed: opening a quotation public share link no longer crashes with `match.token_expires_at.getTime is not a function`. The raw-SQL path mis-typed the timestamp as `Date | null` when in practice the driver returns ISO strings; we now coerce via `new Date(value)` before comparing against now.\n- Fixed: sending a quotation by email no longer rejects with the misleading toast \"Email module rejected send: Invalid input for email.outbound.send\" when `EMAIL_DOMAIN` / `EMAIL_FROM` are unset. The mailer now omits the `from` header when the parsed address can't pass `.email()` validation and lets the routing layer fill in the provider's configured default-from.\n- The send-quotation flow auto-emails the client now that the validation gate above no longer trips — the `email-on-billing-events` subscriber wires `sales.quotation.sent` to the customer's billing email and PDF attachment.\n- Sales surfaces no longer hard-code a `$` prefix on amounts. Each row's `currency` field drives the symbol, so a PKR invoice renders `PKR 100,000.00` instead of `$100,000.00`. Touched files: sales overview KPIs + recent-invoice/quotation strips + aging widget, invoices list, quotations list, clients list (open balance + delete confirm), payments ledger (per-currency totals header + per-row), subscriptions list (cycle total).\n- The sales overview adds a \"By currency\" strip for multi-currency orgs: one card per ISO-4217 code showing outstanding / overdue / paid-this-month / invoice count for that currency, sorted by outstanding balance. Backed by a new `byCurrency: []` array on `sales.dashboard.summary`.\n- The quotation row-action menu item \"Mark sent\" is renamed to \"Send quotation\" so it lines up with the detail-page button.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-bugfix-and-multicurrency.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ce3d76ac-201b-4218-afd2-a06df65917c4","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-collections-page","type":"added","scope":"sales","summary":"New /sales/collections workflow — aging buckets + top-debtors with per-customer + per-bucket bulk reminder fanout.","body":"Adds a finance-team-focused workflow page at `/sales/collections` for chasing overdue receivables. Pairs with the existing `/sales/reports` aged-receivables dashboard — reports stays the analytical view, collections is the action-oriented twin.\n\nThe page renders one currency at a time (defaults to the org's primary currency; multi-currency orgs get a picker) so the aging totals are always interpretable.\n\n- **Snapshot KPIs**: total outstanding, past-due, and the current top-debtor.\n- **Aging buckets**: Current / 1–30 / 31–60 / 61–90 / 90+. Each past-due bucket exposes a \"Send reminders\" button that fans the bucket's pre-collected `invoiceIds` through the existing `sales.invoice.bulk_send_reminders` action. Skips paid / void / draft / no-billing-email rows defensively; the toast surfaces both queued + skipped counts.\n- **Top-debtors table**: 20 customers sorted by total-due descending. Each row shows open invoice count, oldest days overdue, total due in the chosen currency. Per-row \"Remind\" button + multi-select with bulk \"Remind all selected\" fanout. The bulk path fetches each customer's open invoice IDs via `sales.invoice.list` (`companyId` + status filter) then pipes them into `bulk_send_reminders`.\n\nLinked into the sales sub-nav under the Analytics group, alongside Reports.\n\nNo backend schema change — every action and field reused from the existing aging-summary, dashboard-summary, list-invoices, and bulk-send-reminders surfaces.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-collections-page.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e08be865-dd70-4f55-9ce3-33d85c9e6acb","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-detail-and-reports-polish","type":"changed","scope":"sales","summary":"Credit-note + approval detail pages render through formatMoney, reports currency picker upgraded to Picker primitive.","body":"- Credit note detail page renders every amount via `formatMoney(cents, cn.currency)` — header total, line totals, subtotal / discount / tax / total / applied / remaining roll-up, application-row amounts, and the \"Apply to invoice\" modal. Previously the page showed totals without a currency suffix, so a PKR credit looked the same as a USD credit in the UI.\n- The approval queue's per-invoice money column now goes through `formatMoney(BigInt(i.totalCents), i.currency)` instead of the legacy hard-coded-`$` `formatCents` call.\n- The currency filter on /sales/reports is now a real Picker (searchable when > 6 currencies, clearable, \"primary\" hint badge on the org's primary currency). Replaces the native `<select>` so the look + ARIA + keyboard handling matches the rest of the sales surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-detail-and-reports-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"71175b82-47db-4d0a-9471-e0b1401312e3","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-module-polish","type":"added","scope":"sales","summary":"Customer portal, late-fee timeline, late-fee preview, bulk credit distribution, and jurisdiction tax defaults.","body":"- New customer self-service portal at `/portal/<companyId>?token=<token>` — one page showing open balance, every invoice (with deep links into the per-invoice public preview for \"view / pay\"), and credit notes with remaining balance. Tokens are revocable and optionally expire; an admin tab on the client detail page mints, lists, and revokes them.\n- The late-fee cron now writes an `invoiceActivity` row when it applies a fee, so the invoice timeline shows when and why a charge accrued.\n- Invoice detail surfaces a \"next late fee\" preview chip while the policy is armed but not yet triggered, with kind and cumulative cap rendered inline.\n- Credit-note detail gained a \"Distribute remaining\" button that walks the client's open invoices in FIFO order and applies the remaining balance.\n- Invoice and quotation line editors now pre-select the right tax rate when the client's billing country resolves to a single active rate (AU GST, NZ GST, GB VAT). Multi-rate jurisdictions still fall through to the manual picker, and a product's saved default still wins over the jurisdiction default.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-module-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e371b5bf-c9e0-4391-b948-7b69e673f847","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-multicurrency-polish-r2","type":"added","scope":"sales","summary":"Currency filters on invoice + quotation lists, statement currency labels, aging widget per-currency, multi-currency receivables on manager dashboard.","body":"- Statement of account now renders every amount through `formatMoney(cents, data.currency)` — header totals (Invoiced / Paid / Credited), opening + closing balance, and every ledger row carry an explicit currency symbol. Previously the per-currency view showed unlabelled numbers and finance teams had to remember which currency they were looking at.\n- Invoice + quotation list pages gained a currency filter chip — populated from the org's currencies-in-use and only rendered when more than one is present. Backed by a new optional `currency` field on `sales.invoice.list` and `sales.quotation.list` (ISO-4217 uppercase).\n- The receivables aging widget on the sales overview no longer sums across currencies. Multi-currency orgs see a currency chip in the widget header, can tap any of the org's currencies to switch the buckets, and the displayed totals are always currency-clean (powered by the existing `currency` filter on `sales.invoice.aging_summary`).\n- The manager dashboard's receivables tile renders one row per currency when the org spans more than one — same `byCurrency` shape we added to the operator overview last round, now wired through the manager variant too.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-multicurrency-polish-r2.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ac912f76-6339-47db-b515-fb7ca51746cc","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-multicurrency-r3","type":"changed","scope":"sales","summary":"Reports + credit notes go currency-aware — dynamic picker, primary-default, per-currency totals.","body":"- `/sales/reports` no longer ships a hard-coded USD / EUR / GBP shortlist. The currency picker is now driven by the org's actual currencies-in-use (resolved via `sales.dashboard.summary.byCurrency`), so PKR / AUD / JPY / etc. show up alongside the major three. Defaults to the primary currency for multi-currency orgs so the aging report opens on something meaningful instead of a cross-currency sum.\n- Reports' revenue-by-month chart falls back to the primary currency rather than literal `'USD'` when the operator hasn't picked one.\n- Credit notes list header now renders one rollup line per currency when the org has more than one. Single-currency orgs see the legacy \"X issued total · Y unapplied\" line, just routed through `formatMoney(cents, currency)`.\n- Credit notes row totals + \"left to apply\" labels render through `formatMoney(cents, n.currency)` so a PKR credit shows `PKR 100,000.00` instead of an unlabelled `100,000.00 PKR`.\n- The bulk-distribute toast no longer reports a cross-currency sum that would be arithmetically meaningless for multi-currency selections — it counts credit notes distributed instead.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-multicurrency-r3.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"13305089-2548-4b6d-977e-9350f0503e92","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-payment-refunds","type":"added","scope":"sales","summary":"Payment refunds — return money to the customer against a specific payment, distinct from credit notes.","body":"You can now refund a payment from the invoice detail page. Each payment row carries a \"Refund…\" button (gated on `sales:payment:record`) that opens a modal with amount (defaults to full, capped at the payment minus prior refunds), reason picker (`duplicate_charge`, `service_not_delivered`, `customer_requested`, `goodwill`, `fraud_chargeback`, `other`), gateway/bank reference, and notes.\n\nNew `sales_payment_refunds` table (migration `0193_0194`) records each refund with `processed_at`, status (`pending` / `processed` / `failed` — only `processed` ships in v1), and links back to the parent payment + invoice. New actions: `sales.payment.refund` (writes the row + atomically decrements the invoice's `amount_paid_cents` + flips the status back from `paid` / `partial` to `issued` when the net paid drops below zero), `sales.payment.list_refunds` (list refunds for an invoice or a single payment).\n\nDistinct from credit notes: a credit note reverses *invoice value* (customer-facing document); a refund records *the bank-side transaction* of returning collected funds. They often pair but lifecycle independently.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-payment-refunds.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b9f6f679-4f04-4dc2-bd39-9c6c71dc7a2b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-payments-refund-button","type":"added","scope":"sales","summary":"Refund a payment directly from /sales/payments — refundable balance + per-row modal, no need to open the parent invoice first.","body":"`/sales/payments` now exposes a Refund button on every row. The button caps at the payment's net-of-prior-refunds remaining amount; partially-refunded payments show \"<remaining> refundable\" below the original total, fully-refunded payments show \"fully refunded\" and the button disables.\n\nThe modal asks for amount (defaults to the full refundable balance), an optional reason, and an optional external reference (Stripe refund id, bank txn id, cheque number). On submit it calls the existing `sales.payment.refund` action, which decrements the parent invoice's `amount_paid_cents` and flips the invoice status back from paid / partial to issued when the balance reopens.\n\nAlso fixed a pre-existing field-name typo on this page — the local `Payment` type declared `clientName: string` while the action returns `companyName`, so the row's customer label was rendering empty. Now reads `p.companyName` directly.\n\nBackend: extended `sales.payment.list` to return `refundableCents` per row. Computed via a single grouped query over `sales_payment_refunds` rather than an N+1 lookup. PaymentRow schema gained the field; pre-feature consumers still parse because the field is a string, never absent.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-payments-refund-button.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"4f59b267-d1ab-4b3e-a93f-ea7c6f6c844b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-pdf-logo-and-stamp","type":"changed","scope":"sales","summary":"PDF letterhead logo bumped 56→96px; status stamp moved from top-right to centred diagonal stamp across the page.","body":"Two polish items on the invoice / quotation / credit-note PDF layout:\n\n- **Letterhead logo size** bumped from `56×56` to `96×96` (and `40×40 → 64×64` for the monogram fallback). The recipient's first-glance brand recognition matters; the previous size was too modest for the available header real-estate. Object-fit `contain` keeps wordmark logos from squashing.\n- **Status stamp** (\"PAID\" / \"VOID\" / \"OVERDUE\" / \"WRITTEN OFF\") relocated from a small top-right rectangle to a large centred diagonal stamp. New treatment: `top: 38%`, `left: 50%`, `transform: translate(-50%,-50%) rotate(-12deg)`, font size 42px, letter-spacing 6, border 3px. Reads like an actual rubber stamp across the deliverable instead of a discreet badge the recipient can miss.\n\n55/55 sales tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-pdf-logo-and-stamp.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"94525b2b-9e89-4b7c-81a5-e9921f7371dd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-pdf-logo-column-fix","type":"fixed","scope":"sales","summary":"PDF loaders referenced a non-existent organizations.logo_url column — actual column is organizations.logo.","body":"Follow-up to the SQL-comment hotfix (`0a1b26c8`). With comments removed the query parsed cleanly but Postgres still rejected it because the column name was wrong:\n\n```sql\ncoalesce(o.letterhead_logo_url, o.logo_url, p.logo_url) as logo_url\n                                ^^^^^^^^^^\n                                does not exist\n```\n\nThe `organizations` schema defines the column as `logo` (drizzle: `logo: text('logo')`). I'd assumed it followed the `letterhead_logo_url` snake_case naming, but the regular topbar mark is just `logo`. Fixed across all four loaders (invoice, quotation, credit-note, statement). Cascade is now:\n\n```sql\ncoalesce(o.letterhead_logo_url, o.logo, p.logo_url)\n```\n\n— org's letterhead variant (preferred for docs) → org's regular logo → platform default.\n\n55/55 sales tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-pdf-logo-column-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"8619e6d8-2e5b-429b-aaec-61dd13d199b1","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-pdf-org-branding","type":"fixed","scope":"sales","summary":"Invoice / quotation / credit-note / statement PDFs now show the org's logo + legal info, falling back to platform branding.","body":"Reported by staging customer: customer-facing PDFs (invoices + quotations) rendered without the org's logo or watermark and without legal information (registered name, tax ID, address). The PDF template already had slots for all of these — the loader just wasn't reading them.\n\nRoot cause: every PDF loader (`loadInvoiceForPdf`, `loadQuotationForPdf`, the credit-note + statement loaders) joined `platform_settings` for branding but never read the org-level `organizations` row, so per-tenant logos / legal info were ignored. Multi-tenant deployments saw the platform brand stamped on every tenant's document.\n\nFix: each loader now joins `organizations` and `coalesce`s every brand field through the cascade:\n\n1. **Logo**: `organizations.letterhead_logo_url` (preferred — higher-res doc variant) → `organizations.logo_url` (regular topbar mark) → `platform_settings.logo_url` (ultimate fall-back).\n2. **Brand colour**: `organizations.brand_primary` → `platform_settings.brand_primary` → `#7C3AED`.\n3. **Legal name**: `organizations.legal_name` → `organizations.name` → `platform_settings.company_name`.\n4. **Tax ID**: `organizations.tax_id` → `platform_settings.company_tax_id`.\n5. **Address**: `organizations.address` → `platform_settings.company_address`.\n\nThe downstream PDF template already renders all five — header brand block (top-right), bill-from block, and footer legal line — and the watermark layer at line 784 already triggers off `branding.logoUrl` at opacity 0.04. Once the loader populates these fields, the watermark + legal block render automatically.\n\nAffects every PDF the sales module produces:\n\n- Invoice PDFs (operator download + customer-facing share link + email attachment)\n- Quotation PDFs (same surfaces)\n- Credit-note PDFs (operator download + email attachment)\n- Statement-of-account PDFs (client-detail download + email attachment)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-pdf-org-branding.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"780edb2a-7aba-402d-a93b-c8f8f913d1b8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-pdf-sql-comment-hotfix","type":"fixed","scope":"sales","summary":"PDF loader queries failed in prod because inline `--` SQL comments collapsed the query onto one line.","body":"The previous PDF-branding fix (commit `9c7d88c4`) introduced `--` SQL line comments inside the Drizzle `sql\\`...\\`` template literals. Postgres treats `-- ` as \"comment to end of line\", and the template literal collapses newlines when serialized, so the comment swallowed the rest of the query — turning the SELECT into a syntax error and failing every PDF render with `Failed query: ...`.\n\nAffected loaders: invoice, quotation, credit-note (statement was fine — its comment lived outside the SQL string).\n\nFix: drop the inline `--` comments. Explanation that justified the cascade now lives in TypeScript comments outside the template literal so they can't bleed into the query.\n\nPattern note: when documenting raw-SQL fragments in Drizzle's `sql\\`\\`` tag, keep comments OUTSIDE the template literal — anything inside is shipped verbatim to Postgres and a `-- ` comment will silently eat the rest of the statement.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-pdf-sql-comment-hotfix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"67717569-9316-4af5-866c-83629160b398","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-pdf-three-fixes","type":"fixed","scope":"sales","summary":"Invoice / quotation / credit-note PDFs — currency prefix, header spacing, logo URL resolution.","body":"Three customer-reported PDF rendering issues fixed:\n\n1. **Currency display broken.** Line items rendered `¨10,000.00` (a diaeresis glyph substitution because react-pdf's default font lacks the `₨` PKR rupee codepoint) and the `Total` row dropped the symbol entirely. Replaced the per-currency symbol table with an ISO-code prefix everywhere — `USD 65,000.00` / `PKR 65,000.00`. Stable across every currency the org might bill in, and unambiguous regardless of the recipient's PDF reader.\n\n2. **Invoice number + title visually crammed.** `INV-2026-0002` and the optional title rendered with no vertical breathing room. Added an explicit `lineHeight: 1.15` to the doc-number, bumped the title `marginTop` from 4 → 8, and added `lineHeight: 1.3` to the title for multi-line cases.\n\n3. **Org logo not rendering.** Empty box where the letterhead should be. Root cause: `organizations.logo` (or `letterhead_logo_url`) can be stored as a path-only `/api/files/…` proxy URL; react-pdf's server-side `<Image>` fetcher resolves relative to nothing and the fetch fails. Added a `resolveLogoUrl()` helper at the loader layer that:\n    - Returns absolute `https://` / `http://` / `data:` URIs unchanged.\n    - Prepends `BETTER_AUTH_URL` (falling back to `APP_URL`) to any path starting with `/`.\n    - Returns `null` for opaque strings so the template falls through to the monogram block.\n\n   Wired at all four loader callsites (invoice, quotation, credit-note, statement).\n\n55/55 sales tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-pdf-three-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"558e3594-1aec-4011-a917-b7a564055a0b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-polish-batch-2","type":"added","scope":"sales","summary":"Bulk credit-note actions, approval emails, recurring run history, CSV exports, inbound replies, and statement polish.","body":"- **Bulk void + bulk distribute** on the credit-notes list — select multiple rows and apply the remaining balance across each client's open invoices FIFO in one click, or void several drafts/issued credits together.\n- **Approval-request emails** — when an operator clicks \"Request approval\" on an above-threshold invoice, every org owner/admin now gets a templated email + in-app notification with a deep-link to review. New template `sales.invoice.approval_requested` ships in the system seeds.\n- **Recurring template run history** — each row on /sales/recurring expands inline to show the last 20 generated invoices with status badges and deep-links back to each invoice.\n- **CSV export from credit-notes list** — `sales.credit_note.export_csv` action + Export button on the list (UTF-8 with BOM, mirrors the invoice export shape).\n- **Inbound email reply ingestion** — when a customer replies to an invoice email, the reply is automatically threaded back to the invoice's activity timeline (via `In-Reply-To` matching against `email_messages_outbound`, with `INV-NNNN` subject-line fallback). New `customer_reply` activity kind renders with the sender + a 280-char excerpt. Filter chip \"Replies\" added to the timeline.\n- **Statement of account polish** — date-preset shortcuts (This month / Last month / YTD / All time), kind chips (invoice / payment / credit) per row, and clickable references that deep-link to the invoice or credit-note detail.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-polish-batch-2.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ec6611bf-616e-471d-a6f7-ad7eb4e004f1","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-portal-link-in-emails","type":"added","scope":"sales","summary":"Invoice issued/sent/reminder emails now include a secondary \"Manage all your invoices\" portal link when one's been minted.","body":"Once an admin mints a default customer-portal link for a client (Sales › Clients › Portal tab), every outbound invoice email — `sales.invoice.sent` (operator click), `sales.invoice.issued` (auto on issue), `sales.invoice.reminder` — now renders a secondary \"Manage all your invoices\" / \"Review your full account\" link below the primary \"View & pay\" CTA. When no default token exists the link is omitted cleanly — no broken URLs.\n\nLooks up the token by `companyId` + `is_default=true` + `revoked_at IS NULL`; sends nothing if the company hasn't been bootstrapped with a portal.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-portal-link-in-emails.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"979ef85d-734a-4c7d-b793-587f4745fd9c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-reports-revenue-aging-actions","type":"added","scope":"sales","summary":"Per-bucket Send-reminders action, last-12-months revenue chart, and fixed top-debtor deep-link on the Sales reports page.","body":"- **Send reminders to a bucket in one click.** Each past-due aging bucket on /sales/reports now carries a \"Send reminders\" button that fan-outs `sales.invoice.bulk_send_reminders` for every overdue invoice in the bucket. The aging-summary action surfaces the invoice ids per bucket (capped at 200, sorted oldest-due first so partial truncation still surfaces the highest-priority follow-ups).\n- **Last-12-months revenue chart** on the reports page — paid revenue grouped by calendar month, fed by the new `sales.invoice.revenue_by_month` action (currency-scoped; no FX conversion). Tooltip shows month + total + payment count.\n- **Top-debtors row now deep-links to /sales/clients/$id** (was pointing at the list page).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-reports-revenue-aging-actions.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"65736af6-494f-449e-aad9-6b5819aa0dec","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-subscription-cancel-reason","type":"added","scope":"sales","summary":"Subscription cancellation now captures a structured reason; detail page shows it as a banner.","body":"Cancelling a subscription from /sales/subscriptions now opens a small dialog that asks for a reason — curated picker with options like \"Too expensive\", \"Switching provider\", \"Missing features\", plus a free-text \"Other\" — and an optional effective date (leave blank to cancel immediately; set a date to keep one final cycle).\n\nNew `sales.subscription.cancel` action stamps `cancelled_at`, `cancelled_by`, `cancelled_reason` on the row (migration `0192_0193_sales_subscription_cancellation`). The detail page renders a red banner showing the reason / who / when whenever the subscription is in `cancelled` state. `subscription.update({ status: 'cancelled' })` still works for back-compat but doesn't capture the reason.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-subscription-cancel-reason.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"402752e5-0bd8-44e7-a211-7f6513e1ccd6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-subscription-create-ui","type":"added","scope":"sales","summary":"Operator can now create subscriptions from /sales/subscriptions instead of via the API only.","body":"The Subscriptions list page now has a \"New subscription\" button that opens a sheet with client picker, cadence (weekly / monthly / quarterly / annually / custom days), start + trial-end dates, billing day, currency, and a multi-line item editor with per-cycle total preview. The action layer (`sales.subscription.create`) already supported every field — this just unlocks the UX.\n\nAlso: per-currency totals in the header. Previous version summed across currencies which made USD + EUR show as one nonsense number; the new row shows each currency's billable per-cycle total separately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-subscription-create-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"dcd61529-1fbe-46e4-824a-a9ae6b48084f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-subscription-esm-hotfix","type":"fixed","scope":"sales","summary":"Boot crash on staging — runtime require('zod') in cancelSubscription clashed with ESM top-level await.","body":"Staging deploy on 2026-05-27 23:35 UTC rolled back after every web instance crashed at module load with `ReferenceError: Cannot determine intended module format because both 'require' and top-level await are present` (Node 24 ESM, `ERR_AMBIGUOUS_MODULE_SYNTAX`).\n\nRoot cause: `cancelSubscription` declared its input schema via a self-invoking factory that ran `const { z: zod } = require('zod')` to dodge a TypeScript-side type-evaluation order issue. That worked under `tsx` in dev because the dual-mode loader picked CommonJS, but in production where `tsx src/server/prod.ts` runs under strict ESM the runtime `require` is fatal.\n\nFix: replace the runtime require with a top-of-file `import { z } from 'zod'`, and rewrite the schema as a plain `z.object({ … })`. No behaviour change; the schema shape is identical.\n\nCaught + fixed locally:\n- `pnpm --filter @helios/sales typecheck` — clean\n- `pnpm --filter @helios/sales test --run` — 55/55 pass\n- `pnpm --filter @helios/web exec tsx -e \"import('@helios/sales/actions')…\"` — module loads in ESM mode without error","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-sales-subscription-esm-hotfix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d3afd380-685c-4a15-9247-7460a72d2937","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"settings-hrm-skeletons","type":"changed","scope":"hrm","summary":"Sweep — skeleton loaders across 7 HRM settings pages (benefits, comp-review, document-templates, equity, onboarding, retention, rules).","body":"Continues the settings polish sweep — every HRM admin page that\npreviously showed `<p>Loading…</p>` while waiting on its list query\nnow renders a shape-matching skeleton block instead. 13 occurrences\nacross 7 files:\n\n  - `hrm.benefits` — packages list (3-row) + per-employee enrollments\n    list (2-row).\n  - `hrm.comp-review` — cycles list, records list (3-row each), and\n    the budget summary card (title + bar skeleton).\n  - `hrm.document-templates` — templates list (3-row).\n  - `hrm.equity` — grants, vests, and exercises lists (3-row each).\n  - `hrm.onboarding` — task-template list (4-row).\n  - `hrm.retention` — policies + records lists (3-row).\n  - `hrm.rules` — entire page skeleton (3 cards × 2-input each) since\n    the page short-circuits on `get.isLoading`.\n\nSame convention as the earlier-today profile / notifications /\norganization / forms / email.logs / legal / notifications.admin\nsweeps. No behavioral change.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-settings-hrm-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7b004a7e-50ca-4c84-9c6b-3c041abeb69e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"chat-popover-skeletons","type":"changed","scope":"chat","summary":"Four chat popovers — pinned, saved, follow-ups, members — skeleton rows.","body":"Four chat popovers replaced their bare \"Loading…\" text with row-shaped skeletons so the popover settles in place rather than briefly flashing a sentence then snapping to a list:\n\n- **Pinned messages popover** — 3 message-card skeletons.\n- **Saved messages popover** — same shape.\n- **Follow-up reminders popover** — 3 row skeletons (checkbox + message + time).\n- **Members popover** — 4 avatar+name placeholder rows so the chrome doesn't reflow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-chat-popover-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"148adafe-d5ee-4f7e-ace1-97c4092601c0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-industry-picker-slug-icon","type":"changed","scope":"web","summary":"Signup workspace step swaps native Industry dropdown for the polished Picker + adds a Globe leading icon to the URL slug field.","body":"Two lingering basic spots in the signup wizard's workspace step:\n\n- **Industry dropdown** previously used a styled native `<select>`\n  — the popup menu showed the OS-default chrome, which broke the\n  design-system rhythm the rest of the page held to. Now uses the\n  `<Picker>` primitive: filterable popover, keyboard navigation,\n  Helios chrome end-to-end.\n- **URL slug field** had no leading icon, so the URL preview chip\n  below it was the only place anchoring the \"this is your address\"\n  context. Added a leading `Globe` icon to mirror the preview chip\n  + match the iconography pattern every other field in the wizard\n  already followed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-signup-industry-picker-slug-icon.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7c8e2e9e-d1b7-4bb1-a7da-c32bdcb769b9","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-slug-field-fixes","type":"fixed","scope":"web","summary":"Signup workspace step stacks name + URL slug into full-width rows; focusing the auto-derived slug selects it so you can immediately retype to replace.","body":"Two issues on `/signup` step 4 (workspace):\n\n1. **Slug input overflowing its cell.** The workspace name + URL\n   slug were laid out as a `1.4fr / 1fr` grid inside the 28rem\n   auth card, leaving the slug column ~160 px wide. With the\n   40-px leading-icon column, that left ~120 px of typing space\n   — and long values pushed past the rounded border.\n\n   Both fields now stack vertically (single column, `space-y-3`)\n   so each gets the full card width. The URL preview row below\n   already had enough breathing room; with the wider slug input\n   above it the visual rhythm now reads as a clean three-row\n   stack: name → slug → live URL preview.\n\n2. **\"Press to select all doesn't select all\" on the auto-derived\n   slug.** The slug is auto-derived from the workspace name\n   (`slugify(name)`) until the user touches it. The natural\n   gesture is to focus the slug input and type a replacement —\n   but the existing behaviour put the caret wherever the user\n   clicked, requiring them to ⌘A first.\n\n   The slug now selects on focus while it's still in\n   auto-derived mode (`selectOnFocus={!slugLocked}`). Clicking\n   the field highlights the whole value so the next keystroke\n   replaces it. Once the user has manually edited the slug, the\n   lock engages and focus reverts to normal caret-placement\n   behaviour (so they can fix a typo without losing the rest).\n\n   The `selectOnFocus` prop landed on the shared\n   `<PolishedField>` so any future auto-derived field can opt in.\n   It uses `requestAnimationFrame(() => el.select())` to defer\n   the selection past the browser's own focus-selection-collapse.\n\nAlso fixed a small race: the form-store subscriber that\nre-derives the slug from the name now reads `slugLocked` from a\nref instead of a captured closure, so a user's first keystroke\nin the slug input is no longer overwritten in the brief window\nbefore the effect tears the subscription down.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-signup-slug-field-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d7d5576e-c995-430a-848d-0e5c13efa31b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-url-preview-fallback","type":"fixed","scope":"web","summary":"Signup URL preview falls back to \"workspaces.app/\" when the white-labelled platform appName is empty, so it never reads as \".app/your-team\".","body":"The workspace step's live URL preview composed its host prefix\nfrom `useAppConfig().appName.toLowerCase().replace(...)`. On\nwhite-labelled deployments the platform `appName` is empty by\ndesign (the codename sentinel is stripped — see the white-label\nbranding rule), which produced `.app/your-team` — a malformed\npreview that started with a leading dot.\n\nNow the prefix falls back to a generic `workspaces` literal when\nappName resolves empty, so the preview always renders a sensible\nhost like `workspaces.app/your-team`. Deployments that have set\ntheir `appName` continue to see their own brand in the preview.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-signup-url-preview-fallback.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7eee6c77-b8d5-4fd1-9f7c-f508dc248eec","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-url-preview-ticker","type":"changed","scope":"web","summary":"Signup URL-preview row updates the slug in place instead of remounting per keystroke, and gains a soft accent halo when the slug is valid.","body":"The live URL preview (\"yourapp.app/your-team\") under the slug\ninput was keyed on the slug value itself — every character typed\nforced AnimatePresence to unmount the old text and slide in the\nnew one. On rapid typing that read as a flicker.\n\nThe key now switches only on the empty ↔ non-empty transition,\nso the placeholder slides out once and the value slides in once;\nsubsequent edits just mutate the text node in place — a clean\nticker rather than a per-character churn.\n\nCosmetic upgrade alongside: when the slug is valid, the preview\nrow picks up a soft 3 px accent halo (same `var(--ring)` style\nused on focused inputs) so the confirmation reads as more than\njust a colour change.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-signup-url-preview-ticker.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"8e355053-072e-4c62-b8a1-3a70807cac59","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-confirm-badge-polish","type":"changed","scope":"web","summary":"ConfirmDialog gets a danger icon + Cancel-first focus on destructive prompts; Badge gains a pulse-dot variant for live indicators.","body":"Two foundational primitives polished:\n\n**`ConfirmDialog`**\n- For `danger: true` the title now leads with a springing Warning\n  icon in a soft red halo so the destructive intent is impossible\n  to miss before the user reads the copy.\n- Destructive flows focus **Cancel** by default (was: Confirm).\n  Classic UX failure on destructive dialogs is \"user hits Enter to\n  dismiss a previous toast, the Confirm-Delete button has focus,\n  the row is gone\". Cancel-first focus makes the dangerous path\n  always intentional. Non-destructive prompts keep Confirm focused\n  (OK-style dialogs where Enter-to-proceed is correct).\n- Confirm button now passes `pending` through as `loading` so the\n  spinner shows during the parent's mutation.\n\n**`Badge`**\n- New `pulse` prop. When paired with `dot`, the dot is wrapped in\n  a breathing radial halo (existing `ui-breath` keyframe) — signals\n  \"live\" / \"recording\" / \"in-call\" without overwhelming the chip.\n  Off by default; static dot is the typical case.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-confirm-badge-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"0d995edd-6971-4605-b27d-fa89e7a77fe1","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-icon-weights-app-wide","type":"changed","scope":"web","summary":"App shell, dashboards, banners, and project / sales / chat / widget surfaces adopt the new icon-weight tiering.","body":"Extends the auth-surface icon tiering (commit `aa1a13f1`) across\nthe rest of the web app: user-menu, org-switcher, notifications,\nfive dashboard variants (root / operator / manager / employee /\nclient), pending-approvals widget, command-center, every\nplatform banner (impersonation, maintenance, platform\nannouncement, org setup, status incident, verify-email), task-\nviews widgets, project portfolio + tab bar, sales line items,\nKB editor, AI panel, clock widget, PWA titlebar, and assorted\nhelp / module / chat / clients chrome.\n\nTiering rules unchanged from the auth pass — `regular` for\ncontext icons at 14–16px, `bold` for chrome chevrons / arrows /\nsmall pill icons at 11–13px, `fill` for state badges (Warning,\nCheckCircle), `duotone` retained for hero-scale crafted moments\n(maintenance Wrench in the lockout screen, dashboard success\nchecks at 20+).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-icon-weights-app-wide.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a2bdbed9-bbfe-4f64-9f9a-d6a9560691ad","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-icon-weights-routes","type":"changed","scope":"web","summary":"91 route pages adopt the new icon-weight tiering — regular / bold / fill, duotone for hero only.","body":"Final pass of the icon-weight tiering — extends commits `aa1a13f1`\n(auth) + `00717043` (components) to every clean route page across\nthe app: CRM (companies / contacts / leads / deals), HRM (employees\n/ teams / attendance / leave / shifts / time / one-on-ones /\nnotices), recruitment (jobs / candidates / pipeline / interviews\n/ offers / questions / templates), sales (quotations / catalog /\nexchange-rates), payroll (runs / payslips / compensation), projects\n(boards / portfolio / templates), accounting, inventory, chat,\nsupport (tickets / KB / forms), platform admin (saas / settings),\ncareers public surfaces, status pages, payment-gateways, and the\ncalendar / activity / dashboards.\n\nSame tiering rules — `regular` 14–16, `bold` 11–13, `fill` for\nstate, `duotone` retained at 17+ for crafted moments.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-icon-weights-routes.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"e6f69ae4-ac56-4057-8c48-4a403b4b740a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-icon-weights-tiny-chips","type":"changed","scope":"web","summary":"Tiny chip icons (sizes 9–10) upgraded to fill / bold weight for visual clarity at small scale.","body":"Final-mile pass on the icon-weight tiering — five components still\nhad `duotone` icons at sizes 9–10 where the two-layer fill blurs\ninto a soft blob at that resolution.\n\n  - Size 9 (state badges — Warning chip in AI panel): `fill` so\n    the solid shape reads as the signal.\n  - Size 10 (chrome icons — clock-widget trash, schedule Coffee\n    break marker, auth transparency footer globe / clock,\n    module-landing Sparkle): `bold` for crisp stroke.\n\nTouches: ai-panel.tsx, auth-transparency-footer.tsx, clock-widget.tsx,\ndashboard/my-schedule-card.tsx, module-landing.tsx. Pure weight-prop\nswaps.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-icon-weights-tiny-chips.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"bc075c33-839d-48e2-a1c7-6c899fc546a3","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-primitives-batch-polish","type":"added","scope":"web","summary":"New primitives — FieldHint, CountUp — plus polish on Skeleton, Kbd, Tooltip, Separator, Switch.","body":"A foundational pass across `@helios/ui` to fill the remaining\nprimitive gaps and replace ad-hoc patterns with shared components.\n\n**New primitives**\n\n- **`<FieldHint>`** — small inline status chip rendered under form\n  fields (info / success / warning / danger tones). Replaces the\n  hand-rolled \"✓ Looks strong\" / \"⚠ Mismatch\" / \"✓ Not in any leak\"\n  pattern in `PolishedField`, `password-strength`, and other form\n  surfaces. Default state icons use `fill` weight per the tiered\n  icon system; animate-in via motion.\n- **`<CountUp>`** — animated number tween for stat tiles + KPI rows.\n  600 ms ease-out-cubic from the previous value to the new one;\n  honors `prefers-reduced-motion` by skipping the tween. Supports\n  `decimals`, `formatter`, `prefix`, `suffix`.\n\n**Polished primitives**\n\n- **`<Skeleton>`** — gains four new `variant` presets (`text`,\n  `circle`, `avatar`, `card`) so consumers stop hand-rolling row\n  shapes. The `text` variant renders a stack of varying-width lines\n  for a paragraph feel; the last line is short (the\n  \"lorem-ipsum-tail\" trick).\n- **`<Kbd>`** — adds `size` (sm/md) and platform-aware glyph\n  rewriting. Source-code Mac glyphs (⌘ ⇧ ⌥ ⌫ ⏎ ⎋) are\n  automatically remapped to named modifiers (\"Ctrl Shift Alt\n  Backspace Enter Esc\") on Windows / Linux. Detection via\n  `navigator.platform`; SSR matches the source glyphs and a\n  hydration-safe `useSyncExternalStore` snapshot does the swap\n  on mount.\n- **`<Tooltip>`** — adds `variant` (default / info / success /\n  warning / danger). Colored backgrounds tint the chip when the\n  message has state cue value (\"this action is destructive\",\n  \"this control is disabled because…\").\n- **`<Separator>`** — adds an optional `label` slot. Renders as a\n  Section-divider pattern (two gradient lines flanking a small\n  uppercase pill), which is the chrome the auth forms and\n  onboarding pages were previously hand-rolling for \"or with\n  email\" / \"or continue with\" separators.\n- **`<Switch>`** — thumb gets a multi-layer shadow + hairline\n  ring (Stripe-style physical-weight feel) and an `active:scale-90`\n  press-squish. Hover bumps both the off-state border and the\n  on-state accent so the control reads as interactive.\n\nAll new APIs are additive — every existing call site continues to\nwork unchanged. New variants are opt-in via prop.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-primitives-batch-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"53c8108c-2ac4-4ae3-b113-c054bbfb26c0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-primitives-icon-weights","type":"changed","scope":"web","summary":"Core UI primitives (Modal/Sheet/Menu/Picker/Select/DataTable/Pagination/Checkbox/Stat) adopt the tiered icon weights.","body":"Final pass extending the icon-weight tiering to the foundational\nprimitives in `@helios/ui`:\n\n  - **Modal / Sheet** — `XIcon` close buttons at size 14 →\n    `bold` (crisp dismiss affordance).\n  - **Menu** — checked-item Check at size 10 → `bold`.\n  - **Checkbox** — Check / Minus indicators → `bold` for crisp\n    state at any cell size.\n  - **Pagination** — every navigation arrow (first / prev / next\n    / last) → `bold`.\n  - **Picker** — CaretDown trigger, MagnifyingGlass search,\n    XIcon clear, Check selected → `bold`.\n  - **Select** — CaretDown chevron → `bold`.\n  - **DataTable** — sort carets, action icons, search glass,\n    filter clear, view-switcher icons → `bold`.\n  - **Stat** — TrendUp / TrendDown delta arrows → `bold`.\n  - **CommandPalette** — Sparkle hint icons → `bold`.\n\nEvery primitive shipping in `@helios/ui` now follows the same\ntiering rule (regular 14–16 context, bold 10–13 chrome, fill for\nstate, duotone reserved for hero-scale crafted moments). The\nupstream change cascades to every consumer — every DataTable,\nevery Modal, every Picker across the app inherits the polish.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-primitives-icon-weights.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"58959ea0-7943-4421-a44a-2189b4d9f47d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-primitives-polish","type":"changed","scope":"web","summary":"EmptyState gets a motion entrance + icon halo; list skeletons run a gentle shimmer; ListError uses design tokens.","body":"Three foundational state primitives upgraded:\n\n- **`EmptyState`** — wrapper fades in with a 4px lift, the icon\n  container scales in a beat later, and a soft blurred halo sits\n  behind it so the icon no longer floats in dead space. The \"Ask\n  AI\" `Sparkle` switches to `fill` weight.\n- **`ListSkeleton`** — replaces the default `animate-pulse`\n  opacity loop with a horizontal gradient sweep (Stripe / Linear\n  style). Honors `prefers-reduced-motion` via the motion runtime.\n- **`ListError`** — migrates hard-coded `red-50 / red-200 /\n  red-700` to design tokens (`--color-danger-*`), so the alert\n  follows dark-mode + brand-override theming. Warning icon shifts\n  to `fill` weight per the tiering rules.\n\nPure visual upgrades — public API unchanged. Every settings\ncard that uses these states (active sessions, login history,\ntrusted devices, passkeys, linked accounts, API keys, data\nexport) inherits the polish for free.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-primitives-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7ad2920e-d9ed-4ebe-a27f-d8f00c0d7f07","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-tabs-sliding-indicator-fix","type":"fixed","scope":"web","summary":"Tab indicator now actually slides between tabs instead of snap-fading.","body":"The `Tabs` primitive's active-tab indicator looked like motion was\nsliding it from one position to the next, but it never actually did\n— each `TabsTrigger` generated its own `useId()` for the indicator's\n`layoutId`, so motion saw N unrelated elements and just snap-faded\nbetween them.\n\nFixes the slide by hoisting the `layoutId` into a shared\n`TabsLayoutCtx` provided by the `Tabs` root. Every Trigger in the\nsame Tabs group now reads the SAME `layoutId` from context, motion\nrecognises them as one logical element across positions, and the\nindicator slides smoothly from the previous active tab to the new\none (spring stiffness 520 / damping 36 — quick but settled).\n\nThe indicator's visibility is now controlled with `invisible` /\n`group-data-[state=active]:visible` (was `opacity-0` /\n`opacity-100`); motion needs the inactive indicators to stay in\nthe layout flow so it can read their positions, but only the\nactive one should paint.\n\nAffects every Tabs usage in the app — settings tabs, project tabs,\nchat thread tabs, etc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-tabs-sliding-indicator-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7c4f4e95-a5af-4c4e-a374-546d4689a134","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"ui-toast-polish","type":"changed","scope":"web","summary":"Toasts get per-state filled icons + a coloured left rail for a more confident state cue.","body":"The shared `Toaster` mounted at the app root previously relied on\nsonner's default appearance with a barely-visible 25%-opacity\nborder tint. Now every state gets:\n\n- A **filled** icon at 16px — `CheckCircle` (success),\n  `WarningCircle` (error), `Warning` (warning), `Info` (info),\n  spinning `Spinner` (loading). Matches the app's tiered icon\n  system: state badges are filled, chrome is stroked.\n- A **3px coloured left rail** drawn via inset box-shadow so it\n  doesn't shift layout. Confident state cue without painting the\n  whole toast a single hue.\n- A 30%-opacity coloured border that complements the rail at the\n  outer edge.\n\nAffects every `toast.success / error / warning / info / loading`\ncall across the app — sign-in, save confirmations, password\nchanges, OAuth round-trips, all inherit the upgrade for free.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-27-ui-toast-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"40a7802d-fac1-4009-bf5b-c9f9e0cd9371","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"api-rate-limit-headers","type":"added","scope":"api","summary":"Every `/api/actions/*` response now carries `X-RateLimit-*` + `Deprecation` headers.","body":"The action dispatcher now surfaces the per-action bucket state on every\nresponse — 200, 429, and every error in between:\n\n- `X-RateLimit-Limit` — the bucket's max requests per window.\n- `X-RateLimit-Remaining` — requests still available in the current window.\n- `X-RateLimit-Reset` — epoch second when the window resets.\n\nThese follow the de-facto convention shared by Stripe / GitHub / GitLab. Bearer\ncalls (`hak_*` / `hsk_*`) skip the per-IP limiter and so don't carry the\nheaders — they have their own per-key quotas.\n\nActions tagged `deprecated-since:<release>` now also return:\n\n- `Deprecation: true` (per the IETF httpapi deprecation draft) on every call.\n- `Link: </api/actions/<successor>>; rel=\"successor-version\"` when the\n  author set a `replaced-by:<action>` tag.\n\nProgrammatic clients can warn their developers via these headers without\nparsing the OpenAPI spec on every call. Part of the Phase 1 API hardening\nin `docs/plans/PUBLIC_API_AND_DOCS_MODULE_SPEC.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-28-api-rate-limit-headers.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"75371b33-fea4-4846-8fe7-d12133939f55","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-recurring-polish","type":"changed","scope":"sales","summary":"/sales/recurring polish — KPI strip, currency-aware money, richer empty state, due-this-week signal.","body":"Polish pass on `/sales/recurring`:\n\n- **KPI strip** (only renders when there's at least one template) shows Active / Due this week / Paused / Completed. The \"Due this week\" tile scans `nextRunDate` within 7 days of today and accents amber so finance teams can see what fires next.\n- **Currency-aware money** — the run-history row now goes through `formatMoney(cents, t.currency)` instead of the legacy `formatCents` + suffix. A PKR template's invoices render `PKR 65,000.00` instead of unlabelled `65,000.00 PKR`.\n- **Richer empty state** — clearer description (mentions monthly retainer / quarterly licence renewal / weekly maintenance use cases) and surfaces the \"New template\" CTA inline instead of the previous \"create one via the action\" docstring-style hint.\n- **Subtitle** swapped from a redundant count restatement to a useful sentence about the cron cadence and the Generate-now affordance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-28-sales-recurring-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7707b697-5323-47fa-a75d-7dae39c2cd22","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-pricing-catalog-driven","type":"changed","scope":"marketing","summary":"Marketing pricing-page comparison table is now derived live from PLAN_FEATURE_CATALOG instead of hardcoded HTML.","body":"Until now the `/pricing` page's feature-comparison table was a hand-\nmaintained array of string literals — \"1 GB / 20 GB / 200 GB /\nCustom\" for storage, \"Best-effort / 99.5% / 99.9% / 99.95%\" for\nuptime SLA, etc. Any edit to the catalog (a new feature, a tier-\ndefault bump) had to be mirrored manually in\n`apps/marketing/src/components/page-templates/pricing-page-template.tsx`,\nand any drift between the two surfaces went unnoticed.\n\nThe template now imports `PLAN_FEATURE_CATALOG` directly from\n`@helios/saas/feature-catalog` (a workspace dep) and renders each\ncomparison row by piping each plan's `features[catalogKey]` through\nthe catalog's `formatForDisplay` formatter. The result: a single\ncatalog edit propagates to:\n\n- `/saas/plans` admin (already catalog-driven since `8e79fbf9`)\n- `saas.plan.list_public` (the API feed)\n- The `/pricing` marketing page (this commit)\n\nUniversal features (the bottom block — \"Email channels\",\n\"Multi-currency\", \"Custom roles + permissions\", \"Public API +\nwebhooks\", \"MCP server for AI agents\") stay declared inline because\nthey're product truths, not pricing levers.\n\nHighlighted column on the table is now driven by each plan's\n`highlight: true` flag in `plans.json` rather than a hardcoded\nindex — so renaming or reordering plans doesn't accidentally\nhighlight the wrong tier.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-28-marketing-pricing-catalog-driven.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7d28d713-4abf-4a81-8ce0-8f9441809301","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"openapi-export","type":"added","scope":"api","summary":"New `GET /api/openapi.json` endpoint emits an OpenAPI 3.1.1 spec of the action surface.","body":"Every registered action now appears in a machine-readable OpenAPI 3.1.1 document at\n`/api/openapi.json`. The endpoint feeds the upcoming developer docs portal, the\ninternal `/saas/api-explorer`, and SDK generators.\n\n- Unfiltered (default): every action in the registry — suitable for the root-only\n  internal explorer.\n- Filtered: `?tag=public-api` emits only actions explicitly tagged `public-api` —\n  this is the variant the customer-facing docs portal renders via Scalar.\n- White-label safe: spec metadata reads `PLATFORM_APP_NAME` / `HELIOS_RELEASE_TAG`\n  env vars; no codename leaks in. Full DB-backed branding resolution lands when\n  the `api-docs` module ships (see `docs/plans/PUBLIC_API_AND_DOCS_MODULE_SPEC.md`).\n- Recognises the new tag taxonomy: `public-api`, `stability:{stable,beta,experimental}`,\n  `since:<release>`, `deprecated-since:<release>`, `replaced-by:<action>`. Structural\n  tags lift to `x-*` extensions in the spec; module name is derived from the\n  action's leading dot-segment.\n\nFoundation for the Phase 1 work in `docs/plans/PUBLIC_API_AND_DOCS_MODULE_SPEC.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-28-openapi-export.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c9ce5981-d10b-41f3-bd10-818596ed34b7","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-email-composer-polish","type":"changed","scope":"sales","summary":"Invoice + quotation send-by-email modals rebuilt — wider layout, design-system primitives, Bcc field, suggested recipient affordance, \"what's included\" panel.","body":"Polished the send-by-email composer on both `/sales/invoices/$id` and `/sales/quotations/$id` (the user's \"send proposal\" surface).\n\n**Layout + chrome:**\n\n- Bumped modal width `max-w-lg` → `max-w-xl` and added `p-5` padding so the form breathes.\n- Replaced bare `<input>` / `<textarea>` + ad-hoc `<label>` markup with the design-system `Input` + `Label` primitives — consistent focus rings, type, and a11y wiring across the rest of the app.\n- New section headers (\"Recipients\" / \"Email content\") to visually group the form into intent blocks.\n\n**Functional improvements:**\n\n- **Bcc field** added — hidden behind a `+ Cc / Bcc` affordance so the default state stays simple, expands when the operator needs archive copies or DM stakeholders.\n- **Cc + Bcc accept comma- OR newline-separated** addresses (the previous comma-only rule tripped on pasted lists).\n- **\"Suggested:\" recipient chip** appears under the To field when the resolved billing email differs from what the operator has typed — one click pre-fills.\n- **Character count** on the message field shows live once typing begins (4 000 char cap).\n- **\"What's included\" panel** spells out PDF attachment + share link + activity-timeline behaviour so the operator never has to guess what the recipient will see.\n- **Preview button** promoted from a bare underline to the `Button` primitive in ghost variant — matches the action hierarchy of Cancel + Send.\n\nBoth modals are visually identical and follow the same field ordering, so muscle memory transfers between invoice + quotation workflows.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-28-sales-email-composer-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b3a24925-2721-4e00-bcde-a29e977dc55d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-email-preview-polish","type":"changed","scope":"sales","summary":"\"What will be sent?\" email preview popout — bigger iframe, sandboxed body, inbox-style envelope metadata, optional From: line.","body":"Polish pass on the shared `EmailPrefillPreview` used by every send-by-email composer (invoices, quotations, future credit-note + statement composers).\n\n- **Bigger canvas**: modal `max-w-2xl` → `max-w-3xl`, iframe height 420 → 480px. Matches typical desktop inbox preview-pane proportions.\n- **Sandbox** added on the iframe (`sandbox=\"\"` strips scripts + same-origin) so a hand-crafted template body can't escape the preview chrome.\n- **Inbox-style metadata block** — From / To / Subject as a `<dl>` triple inside a subtle Card-like container at the top, matching what the recipient's inbox actually shows. Optional `fromLabel` prop renders the From: line when the composer knows it; falls back to \"your workspace (resolved at send time)\" hint.\n- **Envelope icon** in the header so the modal feels like a mail preview, not a generic dialog.\n- **Template deep-link** moved to the footer next to a Link icon, prose hint about what dispatch adds (share link, attachments, threading) lives alongside it.\n- **`<MetaRow>` helper** keeps each metadata row's label/value alignment consistent (52px label column, right side truncates cleanly).\n\nDrop-in change — every existing call site keeps working; the new `fromLabel` prop is optional.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-28-sales-email-preview-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f87f9766-2711-423c-b7bc-2705334f1666","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-detail-skeleton-loaders","type":"changed","scope":"clients","summary":"/clients/$id — replaced bare \"Loading…\" placeholders with layout-matching skeletons.","body":"Three sub-tabs on the client detail page (Activity, Engagements, Projects) rendered a bare centered \"Loading…\" string while their respective queries were in flight. Replaced each with a layout-matching skeleton (rows + avatars + status pills) so the page settles in place rather than jumping when data arrives.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-clients-detail-skeleton-loaders.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ecbab948-8aa4-4204-abda-8d65554c20ba","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-engagements-list-skeletons","type":"changed","scope":"clients","summary":"/clients + /engagements list pages — 4-5 row skeleton during isLoading.","body":"The two clients-module list pages flipped between an empty container and the empty-state card / populated list during the initial fetch, the same gap the sales lists had. Both now render a skeleton with the right shape (avatar + name + lifecycle pill + amount column) so the right layout lands immediately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-clients-engagements-list-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ff661bce-4deb-4999-bc7f-50ed456e0913","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-support-tab-skeleton","type":"changed","scope":"clients","summary":"/clients/$id Support tab — skeleton rows instead of \"Loading tickets…\" text.","body":"The Support tickets sub-tab on the client detail page rendered centered \"Loading tickets…\" text while its query was in flight. Replaced with 3 row-shaped skeletons (status pill + subject + last-activity time) so the tab settles in place rather than jumping when data arrives.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-clients-support-tab-skeleton.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c495c96a-4b49-4a7c-9630-c6084da07160","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"credit-note-detail-polish","type":"changed","scope":"sales","summary":"/sales/credit-notes/$id polish — KPI strip, richer skeleton, Use-max, metadata footer.","body":"Polish pass on the credit-note detail page:\n\n- **KPI strip** (Total / Applied / Remaining, only on issued notes) — Applied tile turns success-green when there's any history; Remaining tile turns accent when there's balance to spend. Operators no longer need to read the totals table to see what's left.\n- **Skeleton loader** — replaced the bare two-pulse placeholder with a layout-matching skeleton (header chrome + sticky-bar pills + lines) so perceived latency matches the rest of `/sales/*`.\n- **Use max** affordance on the Apply-to-invoice modal — one-click prefill of the remaining credit balance into the amount field. Saves the \"type the exact remaining\" friction.\n- **Metadata footer** — surfaces created date, issued date, void date + reason, and owner in a faint single-line strip below the lines.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-credit-note-detail-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2fe1de74-7640-440b-a2b1-40e78e5bf778","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"crm-deals-multi-currency-honest","type":"fixed","scope":"crm","summary":"/crm/deals KPI totals — bucket by currency, surface \"+N in other currencies\" hint.","body":"The pipeline KPI strip on `/crm/deals` was summing deal amounts across all currencies into a single bigint and rendering with the org default currency code. So a pipeline of one $100k USD deal and one €100k EUR deal would show `USD 200,000.00` open pipeline — a value that doesn't exist in any currency.\n\nFixed:\n\n- **Totals now only sum deals matching the org default currency.** A USD-default org with mixed deals sees the honest USD-only total.\n- **Multi-currency hint** below the KPI strip: \"Totals show USD only. 3 deals in other currencies aren't summed here.\" Surfaces the gap without polluting the headline.\n- **Per-stage column footer** in the kanban also reflects the default-currency-only filter so the column total matches what the headline counts.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-crm-deals-multi-currency-honest.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"46418862-03e8-4ab2-8b3b-3f1e943f7425","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"crm-leads-currency-label","type":"fixed","scope":"crm","summary":"/crm/leads qualify-lead modal — \"Deal amount\" label honors org default currency.","body":"The qualify-lead modal hard-coded \"Deal amount (USD)\" on the field label, regardless of the org's default currency. A PKR-currency org saw \"USD\" next to a field where they were entering rupees.\n\nReplaced with `Deal amount (${defaultCurrency})` so the label reflects what the operator is actually keying in.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-crm-leads-currency-label.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"db156349-2a58-4e8f-908f-ae615644cd63","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"customer-portal-polish","type":"changed","scope":"sales","summary":"/portal/$companyId polish — urgency signals, KPI strip, skeleton loader, print affordance.","body":"Polish pass on the customer self-service portal:\n\n- **Urgency signals on every overdue / due-soon invoice row** — a rose-tinted \"Nd overdue\" pill or amber \"Nd left\" / \"due today\" pill renders inline next to the status. Recipients see at a glance which line items are biting and which are coming up.\n- **KPI breakdown beneath the Open balance** — three signals (Overdue / Due within 7 days / Available credit), each tinted by tone (danger / warning / success). Only renders when at least one signal is non-zero so the page stays calm for accounts in good standing.\n- **Skeleton loader** — replaced \"Loading your account…\" placeholder text with a header + KPI + table skeleton matching the rest of the public-facing surfaces.\n- **Print / save PDF** button in the header (hidden when printing via `print:hidden`) — recipients can save the ledger snapshot to PDF via the browser's print dialog. No new backend needed; the existing layout is print-friendly.\n- **Row hover** on both tables for visual feedback.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-customer-portal-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c10a99ca-66be-4120-90ca-0d62c01d32cd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"entity-chat-side-panels-skeletons","type":"changed","scope":"chat","summary":"Entity-bound channels + entity chat references — skeleton rows.","body":"Two right-rail side panels (rendered on every entity detail page that shows chat references) replaced their bare \"Loading…\" text with row skeletons:\n\n- **Bound-channels list** — 2 channel-row skeletons (icon + name).\n- **Chat references list** — 3 message-card skeletons (author + body preview).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-entity-chat-side-panels-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"8d41a9d2-8aac-402c-a308-e057a73c5589","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"expenses-bare-loading-fixes","type":"changed","scope":"expenses","summary":"/expenses/approvals + /expenses overview — real skeletons during initial load.","body":"Audit follow-ups in the expenses module:\n\n- **/expenses/approvals** — both the Reports and Standalone-expenses cards had NO loading branch, so during the initial fetch they rendered an empty container (the empty-state gate `data?.items.length === 0` is false while `data` is undefined). Now each shows 3 skeleton rows during load and gates its empty state on `!isLoading`.\n- **/expenses overview** — the \"My recent expenses\" and \"Awaiting your approval\" cards each rendered a single flat `h-12` Skeleton bar that didn't match the multi-row, multi-column list below. Replaced with row-shaped skeletons matching the date/description/badge/amount layout.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-expenses-bare-loading-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"3eaf5908-a467-4513-97f1-257d0f5d7686","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"expenses-category-cap-currency","type":"fixed","scope":"expenses","summary":"/expenses/categories — Monthly cap label honors org default currency.","body":"The category create/edit sheet had a hard-coded \"Monthly cap ($)\" label. PKR-currency orgs saw a `$` next to a field where they enter rupee caps. Now reads `Monthly cap (<defaultCurrency>)`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-expenses-category-cap-currency.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a8b99277-73e4-4b02-a31a-919f8543c57e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"expenses-list-skeletons","type":"changed","scope":"expenses","summary":"/expenses/me + /expenses/reports + /expenses/categories — skeleton rows during isLoading.","body":"All three expenses list surfaces now render layout-matching skeleton rows during the initial fetch instead of flipping straight from an empty container to the populated list / empty state. Same pattern that landed across `/sales/*` and `/clients`.\n\n- **/expenses/me** — 5 rows (date + description + category + status pill + amount)\n- **/expenses/reports** — 4 rows (number + title + employee + status + total)\n- **/expenses/categories** — 4 rows (icon + name + description + cap)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-expenses-list-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a34d3dbd-ade9-4afb-b8be-d930fc813478","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-employee-detail-skeleton","type":"changed","scope":"hrm","summary":"/hrm/directory/$id — richer skeleton during isLoading.","body":"The employee detail page rendered a 2-block placeholder (one bar + one larger block) during the initial fetch. Replaced with a layout-matching skeleton: breadcrumb + avatar + name + status pill + three KPI tiles + side-by-side content panels. Matches the polish that landed on the project detail page earlier in the session.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-hrm-employee-detail-skeleton.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"340fa2d5-cccc-470e-83a0-04aac8bda14f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"invoices-products-currency-honest","type":"fixed","scope":"sales","summary":"Three more form labels — invoice credit, invoice payment, product unit price.","body":"Three more form labels were hard-coding `$` regardless of the invoice / product currency:\n\n- **/sales/invoices/$id — Issue credit modal** — \"Amount ($)\" → \"Amount (\\<invoice.currency\\>)\". Recording a credit against a PKR invoice now correctly shows \"Amount (PKR)\".\n- **/sales/invoices — Record payment dialog** — same fix; the Amount label now reflects the invoice's own currency.\n- **/sales/products — Create/edit sheet** — \"Unit price ($) *\" → \"Unit price *\". The CurrencyPicker sits in the adjacent column already; the parenthetical was redundant and wrong for non-USD orgs.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-invoices-products-currency-honest.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2f85c8c3-e2ab-4173-a8e8-c06bc981dfd4","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"marketing-pricing-dynamic-plans","type":"changed","scope":"marketing","summary":"Marketing pricing page is now fully data-driven — any admin-created plan flows through without code changes, including custom slugs and one-time billing.","body":"The marketing `/pricing` page used to hard-code the four\nseed plan slugs (`free | starter | business | enterprise`)\nin three places: a slug-keyed CTA map, an enumerated TS\ntype, and a slug-conditional comparison table. Adding a\nnew tier on `/saas/plans` had no path to the marketing\npage — the page would render the new card but with no\nCTA, the wrong column highlight, and missing comparison\nrows.\n\nNow every code path is dynamic.\n\n**Plan slug is `string`.** The `Plan` shape on the\nmarketing side accepts any slug; `CTA_BY_SLUG`'s removal\nmeans a new admin tier renders correctly the moment it's\npublished.\n\n**CTA derives from plan attributes.** `ctaForPlan(plan)`\nwalks the row:\n\n- `billingInterval: 'none'` → \"Talk to sales\" → `/contact`\n- `billingInterval: 'one_time'` → \"Buy →\" → `/signup?plan=<slug>`\n- `priceAmountCents === 0` (recurring) → \"Start free →\"\n- `trialDays > 0` → \"N-day trial →\"\n- otherwise → \"Get started →\"\n\nTrust note becomes \"Most popular ★\" automatically when the\nplan is the upsell-target (see below).\n\n**`isHighlighted` is computed in the action, not the\nsnapshot.** `saas.plan.list_public` now picks the highest-\nsortOrder recurring tier with a non-zero price and stamps\n`isHighlighted: true` on it. Admins reorder on\n`/saas/plans` → the marketing-page upsell card moves to\nmatch. Removes the hard-coded `business` slug from the\nmarketing template.\n\n**Sort order propagates.** Cards sort by `sortOrder` ASC\n(falling back to price for older snapshots), matching the\nadmin's drag-to-reorder semantics. The grid columns adapt\n(`lg:grid-cols-3` for 3 plans, `-4` for 4, `-5` for 5+).\n\n**Comparison table iterates the catalog.** Instead of a\nhand-curated `orderedKeys` array, `buildComparisonRows`\nsorts `PLAN_FEATURE_CATALOG` by category (Limits → Auth\n→ Compliance → Observability → Support → Branding) and\nemits a row per non-module entry. A new catalog flag\nappears in the marketing comparison the moment it lands\nin the catalog file — no hand edit required.\n\n**Billing label respects every interval.** New\n`billingLabelFor(plan, cycle)` renders:\n\n- `month` recurring → \"/ org / mo\" (or \"/ org / mo billed\n  annually\" when annual cycle is active)\n- `year` recurring → \"/ org / yr\"\n- `one_time` → \"billed once\"\n- `none` → \"\"\n- 0¢ recurring → \"forever\"\n\n**Trial row added.** Comparison table gains a \"Trial\"\nrow above the catalog rows so it's visible at a glance\nwhich tiers ship with a trial without scanning the\ncomparison body.\n\n**Schema additions.** `PublicPlanRow` gains `sortOrder:\nnumber` + `isHighlighted: boolean`. The action sets both\ndeterministically. Two new tests assert: the\nhighest-sortOrder recurring tier with a price wins\n`isHighlighted: true`; everything else is `false`; sort\norder propagates verbatim. saas test count: 252 → 254.\n\nNo DB change. No action policy / dispatch change.\nMarketing snapshot regenerates idempotently from the\nauthoritative catalog.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-marketing-pricing-dynamic-plans.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"212f07b3-39ba-4c59-ae30-6fb5a178adff","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-admin-crud-paddle-crons","type":"added","scope":"payments","summary":"Payments admin UI gets full provider + routing CRUD; Paddle adapter lands as plugin-contract proof; worker crons scheduled.","body":"# Payments Phase 1H — admin CRUD + Paddle + crons\n\nBuilds on the Phase 1 foundation to make the module usable end-to-end without touching the API directly.\n\n## What's new\n\n- **Provider CRUD dialog** in `/settings/payments` — connect / edit / test / delete a provider from the UI. The connection form **auto-renders from the descriptor's `formManifest`** (presets + per-field type/help). Adding a new provider in Phase 2 = one file in `packages/payments/src/providers/<slug>.ts` + one descriptor seed row; **zero UI changes**.\n- **Routing rule CRUD dialog** — author `(event_class × currency × amount)` rules from the UI. Validates against the loaded provider list; surfaces the resolver's `match_specificity` so admins can predict precedence.\n- **Test / Delete buttons** in the provider table; **Edit / Delete** buttons in the routing rules table.\n- **Webhook URL surfaced inline** in the provider dialog and provider table footer — admins can copy the exact URL (`/api/payments/webhooks/<kind>?providerId=<uuid>`) without leaving the page.\n- **`payments.provider.catalog` action** — returns the descriptor catalog (`slug`, `displayName`, `capabilities`, `formManifest`) so the admin UI can render the kind picker + auto-form.\n- **Paddle adapter** at `packages/payments/src/providers/paddle.ts` — merchant-of-record path with `transaction.completed → intent.succeeded` event mapping, Paddle-format HMAC signature verify (`ts=…;h1=…`), sandbox toggle, and hosted-checkout intent flow. **300 lines, single file** — proves the \"10s of new payment methods, one file each\" promise.\n- **Worker crons scheduled** in `apps/worker/src/payments-cron.ts`:\n  - `drain-retries` — every 5 min, picks up failed intents with elapsed `next_retry_at`\n  - `session-expire` — every 5 min, flips stale open `payment_sessions` to `expired`\n  - `health-score` — hourly, rolls up per-provider success rate over the past 24h\n- **8 new Paddle tests** (signature verify happy path / mutated body / replay / wrong secret / missing header; event parse for `transaction.completed`, `adjustment.created`, unknown). **Total Phase-1 test count: 42 passing.**\n\n## What's still deferred\n\nFull Stripe.js Elements wiring + CSP headers, subscription billing recurrence + dunning state machine, dispute evidence UI, customer portal page (`/pay/portal/<customerToken>`), Razorpay / PayPal adapters, and consumer wrappers in sales / saas / marketing / signup. All independent of the foundation; each is a 1-2 day task.\n\nPhase-1 plan: [docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md](docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payments-admin-crud-paddle-crons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"de81a7a3-a88d-4f33-8e85-21fbf306c7cd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-consumer-integrations","type":"added","scope":"payments","summary":"Consumer integrations — Sales pay-link + SaaS activation + Marketing checkout + receipt email/PDF + PayPal adapter.","body":"# Payments — consumer integrations + receipt fabric + PayPal\n\nBuilds on the Phase 1 + 1H + 1J foundation by wiring the payments module into its four consumer surfaces and shipping the receipt PDF + email fabric. The module is now functionally end-to-end for Sales invoicing, SaaS subscription activation, and marketing-site plan purchases.\n\n## What's new\n\n### Receipt + dunning fabric (module-internal)\n\n- **Receipt PDF template** at [modules/payments/src/documents/receipt.tsx](modules/payments/src/documents/receipt.tsx) — built on `@helios/documents` primitives (LetterLayout + KeyValueTable + watermark=\"final\"). Renders amount, paid date, method summary, transaction id, fee / net breakdown.\n- **PDF render + upload helper** at [modules/payments/src/lib/receipt-pdf.tsx](modules/payments/src/lib/receipt-pdf.tsx) — keyed at `payments/<orgId>/receipts/<chargeId>.pdf`. Idempotent (re-runs overwrite same bytes).\n- **Two email templates** seeded under `payments.*` in [modules/email/src/seeds/templates/payments.ts](modules/email/src/seeds/templates/payments.ts):\n  - `payments.receipt` — sent on `payments.charge.succeeded`. Carries the receipt PDF as an attachment when render succeeds.\n  - `payments.payment_failed` — dunning, sent on `payments.intent.failed` when a retry is scheduled. Reserved tone for non-terminal failure.\n- **Two email flow registrations** in `EMAIL_FLOWS` so the admin email-flows tab + AI metadata pick them up.\n- **Two subscriber jobs** in `modules/payments/src/jobs/`:\n  - `email-on-charge-succeeded.ts` — renders PDF → mints portal token → dispatches `payments.receipt` template\n  - `email-on-intent-failed.ts` — dispatches `payments.payment_failed` only when `willRetry=true`\n- **Portal-token issuance action** `payments.portal.issue_token` — mints a long-lived (30-day default) sha256-hashed token so receipt emails can deep-link to `/pay/portal/<token>`.\n\n### Sales integration\n\n- **New action** `sales.invoice.send_payment_link` at [modules/sales/src/actions/invoice-payment-link.ts](modules/sales/src/actions/invoice-payment-link.ts) — mints a checkout session for the invoice's remaining balance via `initiatePayment`. Returns the `publicUrl` for the operator to email or embed.\n- **New subscriber** `payment-on-payments-charge-succeeded.ts` — listens for `payments.charge.succeeded` with `sourceKind='sales_invoice'`, invokes `sales.payment.record` so the invoice flips to paid (or partial). Uses the gateway charge id as the external reference so duplicate fires (provider retry) don't double-record.\n\n### SaaS integration\n\n- **New subscriber** `saas-on-payments-charge-succeeded.ts` in `modules/saas/src/jobs/` — listens for `payments.charge.succeeded` with `sourceKind='saas_subscription'`, flips the subscription from `trialing` → `active` via `saas.subscription.set`. Re-fire safe (no-op when already active). Stamps `externalRef = '<providerId>:<providerChargeId>'` for traceability.\n\n### Marketing integration\n\n- **Marketing-site plan purchase** routes the pricing-page \"Buy\" button to `/signup?plan=<slug>`, where the buyer creates an account and pays through the signup wizard — so the org is always created with a verified owner. (An earlier `payments.marketing.create_plan_checkout` direct-buy action was removed before release: resolving an org owner from an unverified, publicly-supplied email is an identity-hijack vector. A no-account direct-buy flow with email verification is deferred.)\n\n### PayPal adapter\n\n- **PayPal provider** at [packages/payments/src/providers/paypal.ts](packages/payments/src/providers/paypal.ts) — ~310 lines, one file. OAuth2 client-credentials auth, hosted checkout via Orders v2 with `application_context.return_url`, refund via captures endpoint, HMAC signature verify with crc32-of-body + raw-body-fallback to tolerate sandbox env. Event mapping for `PAYMENT.CAPTURE.{COMPLETED,DENIED,REFUNDED}` + `CUSTOMER.DISPUTE.*` + `BILLING.SUBSCRIPTION.*`. Brings provider count to **5** (manual, stripe, paddle, razorpay, paypal).\n- **9 new PayPal tests** — signature verify (raw-body fallback / body mutation / replayed timestamp / missing header / missing transmission id) + event parse (`PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED`, `CUSTOMER.DISPUTE.CREATED`, unknown). **Total: 59 tests passing** (was 50).\n\n## Affected modules\n\n| Module | Changes |\n|---|---|\n| `packages/payments` | +PayPal adapter + descriptor seed (auto via `ALL_PROVIDERS`) |\n| `modules/payments` | +receipt PDF + render helper + 2 subscribers + portal token |\n| `modules/email` | +2 system templates + 2 flow registrations |\n| `modules/sales` | +`sales.invoice.send_payment_link` action + invoice-paid subscriber |\n| `modules/saas` | +activate-on-charge subscriber |\n\n## What's still deferred\n\n- **Org creation on webhook success** for marketing/signup flows — the `payment_sessions.pending_org_payload` is captured; the subscriber that turns it into a real org lands next.\n- **Signup wizard `/signup` integration** — inserting a payment step between workspace + modules steps.\n- **Subscription billing recurrence + dunning state machine** (Phase 2 — the spec calls for `payments.subscription.{create,activate,cancel,reactivate}` actions + a `subscription-renew` cron).\n- **Dispute evidence submission UI** (Phase 3).\n- **Adyen + GoCardless adapters** (Phase 4).\n- **Customer portal add-method flow** — portal page is read+detach only.\n\nSpecs of record: [docs/plans/PAYMENT_GATEWAY_MODULE_SPEC.md](docs/plans/PAYMENT_GATEWAY_MODULE_SPEC.md) + [docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md](docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payments-consumer-integrations.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"40eb87f9-a88d-4ca3-8cfd-f9487a96e7a2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-foundation","type":"added","scope":"payments","summary":"Unified payment gateway module foundation with Stripe + Manual adapters, signed webhooks, and routing engine.","body":"# Payments module — Phase 1 foundation\n\nShipped the unified payment fabric — multi-tenant, multi-provider, per-(event_class × currency × amount) routing. Mirrors the System Email module's two-layer shape.\n\n## What's new\n\n- **`packages/payments`** — provider plugin contract (`ProviderDescriptor<TConfig>`) + `stripe` and `manual` reference adapters. Adding a new provider is one file + one descriptor seed row.\n- **`modules/payments`** — 23 actions across `payments.{provider, routing, customer, method, intent, charge, refund, session, webhook, metrics}.*`. All idempotency-keyed; PCI-guarded at runtime.\n- **Schema (migration `0197_0198`)** — 12 tables: `payments_provider_descriptors`, `payment_providers`, `payment_routing_rules`, `payment_customers`, `payment_methods`, `payments_intents`, `payments_charges`, `payments_refunds`, `payments_disputes`, `payments_webhook_events`, `payment_sessions`, `payment_customer_tokens`.\n- **Routing engine** — pure resolver: `(orgId, eventClass, currency, amountCents)` → `providerId`, ordered by specificity DESC then priority ASC. Wildcard, prefix, and exact patterns.\n- **Webhook ingestion** at `POST /api/payments/webhooks/<slug>?providerId=<uuid>` — raw-body capture, HMAC + replay-window verification, dedup by `(provider_id, provider_event_id)`. Stripe signatures verified with constant-time compare.\n- **Public checkout** at `/pay/c/<sessionId>` (hosted-mode + Elements-mode shell) and `/pay/return/<sessionId>` (post-redirect polling).\n- **Admin UI** at `/settings/payments` (tenant) + `/saas/platform/payments` (root only) — provider list + routing rules + charge ledger.\n- **34 tests passing** — PCI guard (9), routing resolver (16), Stripe signature verify + event parsing (9).\n- **Permissions** — `payments:*` namespace (30 keys) + root-only `platform:payment_gateway:manage`. `ADMIN_PAYMENTS` set wired into `admin` blueprint; `READ_PAYMENTS` into `manager`.\n- **Encrypted-at-rest** — `config_encrypted` and `webhook_secret_encrypted` use AES-256-GCM via the same `HELIOS_DATA_ENCRYPTION_KEY` env var as the email module.\n- **Biome `noRestrictedImports`** rejects `stripe`, `@paddle/paddle-node-sdk`, `razorpay`, `@paypal/checkout-server-sdk`, `@adyen/api-library` outside `packages/payments/**`.\n- **`initiatePayment(ctx, args)`** wrapper exported from `@helios/payments-module` for cross-module use (sales, saas, marketing, signup wrappers ship in Phase 2).\n\n## Deferred to Phase 2\n\nSubscription billing recurrence + dunning, full Stripe.js Elements wiring with hardened CSP, dispute evidence UI, Paddle / Razorpay / PayPal adapters, customer portal page, and consumer wrappers in sales / saas / marketing / signup.\n\nSpec: [docs/plans/PAYMENT_GATEWAY_MODULE_SPEC.md](docs/plans/PAYMENT_GATEWAY_MODULE_SPEC.md). Phase-1 plan: [docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md](docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payments-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c7eae873-9dd6-48f2-96df-1515ba1ffbc2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-paid-org-creation-loop","type":"added","scope":"payments","summary":"Paid signups create a tenant org automatically on payment success — plus a fix so payments actions actually register.","body":"# Payments — paid-org-creation loop (signup → pay → org)\n\nCompletes the marketing/signup → checkout → tenant-creation chain. A buyer who picks a paid plan during signup now pays, and their tenant org + owner membership + seeded subscription are created automatically the moment the payment confirms.\n\n## What's new\n\n- **Org-creation subscriber** ([modules/saas/src/jobs/saas-on-payments-session-confirmed.ts](modules/saas/src/jobs/saas-on-payments-session-confirmed.ts)) — listens for `payments.session.confirmed`; for `signup_plan_purchase` / `marketing_plan_purchase` sessions it reads the `pending_org_payload` off the session row and invokes `saas.organization.create` (which seeds the subscription internally). Idempotent: the new org id is backfilled onto the session, so a re-fired confirm no-ops. Slug-collision-safe (numeric-suffix retry).\n- **Signup-wizard plan step** ([apps/web/src/routes/signup.tsx](apps/web/src/routes/signup.tsx)) — a new step between Workspace and Modules. Free plans pass straight through (org created in the modules step, exactly as before). Paid plans create a `signup_plan_purchase` checkout and redirect to `/pay/c/<id>`; the org is created server-side on confirm. Deployments with no paid plan **auto-skip** the step, so the existing free-signup flow is byte-for-byte unchanged.\n- **Authenticated checkout action** `payments.signup.create_plan_checkout` — the wizard's plan step calls it; it reads the signed-in buyer as the future org owner (owner id is server-derived from the session, never client input) and rejects free plans.\n- **Webhook confirm safety-net** ([modules/payments/src/actions/webhook.ts](modules/payments/src/actions/webhook.ts)) — the webhook now confirms an open session itself if the buyer closes the tab before `/pay/return` polls, so the org still gets created. Race-safe: a conditional `open→confirmed` UPDATE with `RETURNING` ensures only one writer emits the confirm event.\n- **Return-page hand-off** ([apps/web/src/routes/pay.return.$sessionId.tsx](apps/web/src/routes/pay.return.$sessionId.tsx)) — org-creating flows get a \"Go to your workspace\" CTA on success.\n\n## Fixed\n\n- **Payments actions were never registered.** `modules/payments/src/actions/index.ts` was a pure `export *` with no `registerAction` calls, so `getAction('payments.*')` returned `undefined` at runtime and every cross-module guard (`if (!action) return`) silently no-opped — the entire payments surface (admin UI, sales pay-link, marketing checkout) was dead at runtime despite typechecking. Added explicit registration for all 36 actions (mirroring the email module) + a `registry.test.ts` regression guard that asserts each one resolves.\n\n## Hardening (from an adversarial multi-agent review)\n\nThe loop was reviewed by a 5-dimension adversarial workflow; 15 verified findings were fixed before ship:\n\n- **Idempotency / no duplicate orgs** — `payments.session.confirm` now uses a status-guarded conditional UPDATE with `RETURNING` (matching the webhook safety-net), so a session confirms — and `payments.session.confirmed` fires — exactly once even when the webhook and `/pay/return` race. The subscriber backfills `session.orgId` with an `IS NULL`-guarded conditional UPDATE and bails if it lost the race, so two invocations can never link two orgs to one paid session.\n- **No silent money-loss** — on a transient `saas.organization.create` failure the subscriber now **throws** (so the event dispatcher retries) instead of returning; it only ever throws *before* an org exists, so retries never double-create.\n- **Identity-hijack closed** — the marketing path no longer resolves an org owner by (attacker-controllable, public) email. Only `signup_plan_purchase` — whose `ownerUserId` is server-derived from the authenticated session — creates an org. The now-redundant public `payments.marketing.create_plan_checkout` action was removed (it would have stranded any payment made against it); the pricing page's \"Buy\" routes to `/signup?plan=<slug>` instead.\n- **Always-valid slug** — `normalizeSlug` now guarantees a SlugZ-valid result (random fallback for all-symbol/empty names; final collision-retry uses a random suffix so a paid session can never be stranded by slug exhaustion). 20-case regression test added.\n- **Correct module-prefs permission** (`iam:organization:update`, not `iam:workspace:manage`) so the buyer's selected modules actually apply.\n- **Signup UX** — the free-only deployment back-button no longer targets the auto-skipped plan step; the stale signup draft is cleared on the payment-return page; the email-verify notice no longer shows on the plan step.\n\n## What's still deferred\n\n- Marketing direct-buy WITHOUT a prior account (no-user owner-invitation flow) — the canonical marketing path routes buyers through `/signup?plan=<slug>`, where an account exists before payment.\n- Subscription billing recurrence + dunning state machine (Phase 2).\n- Adyen + GoCardless adapters (Phase 4).\n\nSpecs: [docs/plans/PAYMENT_GATEWAY_MODULE_SPEC.md](docs/plans/PAYMENT_GATEWAY_MODULE_SPEC.md) + [docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md](docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payments-paid-org-creation-loop.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ac777715-af74-4a1f-b771-d2e341d4798a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-portal-elements-razorpay-csp","type":"added","scope":"payments","summary":"Customer portal, real Stripe.js Elements integration, hardened CSP, Razorpay adapter, and dispute admin view.","body":"# Payments Phase 1J — public portal, real Elements, Razorpay, CSP\n\nBuilds on Phase 1H to close every Phase-1 surface gap. The module is now feature-complete for SAQ-A-EP at the action + UI layer.\n\n## What's new\n\n- **Customer portal at `/pay/portal/<token>`** — public self-service page (token is the auth, sha256'd server-side and looked up in `payment_customer_tokens`). Customers view saved methods, mark a default, detach cards, and see receipts. Three new actions: `payments.portal.view`, `payments.portal.detach_method`, `payments.portal.set_default_method`.\n- **Real Stripe.js Elements integration** on `/pay/c/<sessionId>?mode=elements` — loads `https://js.stripe.com/v3/` on demand, mounts a `PaymentElement` against the intent's `client_secret`, calls `stripe.confirmPayment` on submit with `return_url=/pay/return/<sessionId>`. The session.create action now persists the hosted-checkout URL in `session.metadata.hostedUrl` so session.get can return it without re-calling the provider.\n- **SAQ-A-EP CSP** for all `/pay/*` routes — `script-src 'self' https://js.stripe.com 'unsafe-inline'`, `frame-src https://js.stripe.com https://hooks.stripe.com`, `connect-src 'self' https://api.stripe.com`, `frame-ancestors 'none'`, `X-Frame-Options: DENY`, `Strict-Transport-Security`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy: payment=(self)`. Applied in `apps/web/src/server/prod.ts:serveStaticOrSpa` before the SPA fallback responds.\n- **Razorpay adapter** at `packages/payments/src/providers/razorpay.ts` — **~280 lines, one file**. INR market default with UPI / netbanking / wallets support. HTTP Basic auth, Razorpay-format HMAC signature verify (no timestamp — relies on server-side dedup), hosted checkout via Orders + Payment Links, refund flow, event mapping (`payment.captured`, `refund.processed`, `payment.dispute.created`, `subscription.activated`). Brings the provider count to **4** (manual, stripe, paddle, razorpay).\n- **Dispute admin view** — read-only Disputes card in `/settings/payments` with reason + status badge + evidence-deadline highlight (red when past due). New `payments.dispute.list` action.\n- **8 new Razorpay tests** (signature verify x4 + event parse x4). **Total: 50 tests passing** (was 42).\n\n## What's still deferred to Phase 2+\n\n- Subscription billing recurrence + dunning state machine (~7 days)\n- PayPal + Adyen + GoCardless adapters (each ~1 day per the proven plugin contract)\n- Dispute **evidence submission** UI + provider API call (Phase 3)\n- Customer portal \"add a new method\" flow (currently view-only on methods)\n- Consumer wrappers in sales / saas / marketing / signup (Phase 2)\n- Receipt PDF generation via `@helios/documents` (Phase 2)\n- Email subscribers for `payments.intent.succeeded` → receipt; `payments.intent.failed` → dunning (Phase 2)\n\nPhase-1 plan: [docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md](docs/plans/PAYMENT_GATEWAY_MODULE_PHASE1_PLAN.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payments-portal-elements-razorpay-csp.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b4f9d583-7195-4989-9710-b3f2a0df558b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-security-hardening","type":"security","scope":"payments","summary":"Fail-closed secret encryption, webhook replay-window + rate-limit, and honest provider capabilities.","body":"# Payments — security + correctness hardening\n\nCloses the highest-priority findings from the payments module state audit (the p0/p1 security + correctness gaps), ahead of the larger Phase-2 work.\n\n## Security\n\n- **Encryption fails closed in production.** `assertEncryptionConfigured()` ([packages/payments/src/crypto.ts](packages/payments/src/crypto.ts)) now throws at boot when `HELIOS_DATA_ENCRYPTION_KEY` is missing or invalid in production — previously a forgotten env var silently stored provider API keys + webhook signing secrets as **plaintext**. Off-production still warns and falls back (so local dev + tests need no key). Wired into the web app's boot path; covered by 7 new tests.\n- **Webhook replay-window enforced at the edge.** [apps/web/src/server/payments-webhooks.ts](apps/web/src/server/payments-webhooks.ts) now rejects a validly-signed event whose provider timestamp is older than the adapter's `webhookMaxAgeSeconds` (Stripe/Paddle/PayPal = 300–600s; Razorpay = 0 opts out and relies on the dedup index). A leaked historical payload can no longer be re-submitted days later.\n- **Webhook endpoint rate-limited.** Per-provider token bucket (1000/60s) sheds floods before any DB/crypto work — a compromised signing secret can't hammer the dedup log + event bus.\n\n## Correctness\n\n- **`drain-retries` no longer corrupts retry state.** The Phase-1 sweep ([modules/payments/src/jobs/drain-retries.ts](modules/payments/src/jobs/drain-retries.ts)) previously claimed failed intents to `processing` then reset them to `failed` with `next_retry_at = null` — silently destroying the retry pointer the Phase-2 dunning engine will need, while never re-dispatching. It is now strictly observe-only: it counts intents awaiting retry and logs the backlog (so the gap is visible in ops), and mutates nothing.\n- **Honest provider capabilities.** Stripe/Paddle/Razorpay/PayPal previously declared `subscriptions: true` despite implementing no `subscriptionCreate`/`subscriptionCancel` — misleading the routing layer into advertising a recurrence they can't execute. Set to `false` until the Phase-2 subscription engine lands.\n\n## Still open (next batch, from the state audit)\n\n- The public `/i/$invoiceId` \"Pay\" button + an operator \"Send payment link\" button (needs a token-gated public payment action — deferred for its own tested pass).\n- Provider-refund → invoice `partially_refunded` subscriber.\n- `/pay/*` org-branding + design-token color cleanup.\n- Action-handler + webhook-edge test coverage.\n- Phase 2: subscription recurrence + dunning state machine.\n\nFull assessment: [docs/plans/PAYMENT_GATEWAY_STATE_AUDIT.md](docs/plans/PAYMENT_GATEWAY_STATE_AUDIT.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payments-security-hardening.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"120c06dd-e854-4d51-b539-29a9f7d88e75","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payroll-group-employee-skeleton","type":"changed","scope":"payroll","summary":"/payroll/groups/$id add-employee dialog — skeleton instead of \"Loading employees…\" text.","body":"The \"Add employees\" dialog on a payroll group showed bare \"Loading employees…\" text while the employee roster query was in flight. Replaced with four skeleton rows so the list lands in place when data arrives.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-payroll-group-employee-skeleton.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d6796263-961c-43d2-bd96-9a15270adc9f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"project-detail-skeleton","type":"changed","scope":"projects","summary":"/projects/$projectId — full-page skeleton instead of \"Loading project…\" text.","body":"The project-detail page rendered a single line of \"Loading project…\" centered text while the project's metadata was in flight. Replaced with a full layout-matching skeleton (icon + title + status pill + KPI tiles + content body) so the page lands in place rather than reflowing when data arrives.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-project-detail-skeleton.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"9a5281a5-9c68-4b42-b918-df94098ea210","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"projects-cycle-skeletons","type":"changed","scope":"projects","summary":"/projects/cycles/$id — page + backlog + active-board skeletons.","body":"Three loading states on the cycle detail page replaced with layout-matching skeletons:\n\n- **Page-level** — header + 4 KPI tiles + side-by-side body skeleton instead of a centered \"Loading cycle…\" sentence.\n- **Backlog list** — 5 row skeletons (priority dot + number + title) instead of \"Loading tasks…\" text.\n- **Active board** — 6-column board with 3 card skeletons per column instead of a single flat \"Loading tasks…\" panel.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-projects-cycle-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"33950224-9345-4f3f-9006-88434412f00c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"projects-entity-link-picker-skeleton","type":"changed","scope":"projects","summary":"/projects/$id data tab — entity-link picker shows skeleton rows during search.","body":"The entity-link picker (used when adding an entity-link data row to a project) showed centered \"Loading…\" text in the results pane while the search query was in flight. Replaced with 4 placeholder rows (label + sublabel) so the picker stays visually stable as the user types.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-projects-entity-link-picker-skeleton.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b72c598d-2992-4e91-9bcd-1292aeff45c2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"projects-index-team-shimmer","type":"changed","scope":"projects","summary":"/projects/$id header — inline shimmer instead of \"Loading team…\" text.","body":"The project detail header showed literal \"Loading team…\" text in its subtitle row while the team query (looking up the team name + task prefix for the breadcrumb) was in flight. Replaced with a small inline shimmer bar matching the expected text width.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-projects-index-team-shimmer.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"adc07254-ca66-41c7-b67d-78b9824d7290","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"public-surface-skeleton-polish","type":"changed","scope":"sales","summary":"Public /i/$invoiceId + /q/$quotationId surfaces — bigger header logo, full skeleton loaders.","body":"Polish pass on the public recipient-facing invoice and quotation pages:\n\n- **Header logo** bumped from `h-9 w-9` (36px) to `h-12 w-12` (48px) on `/i/$invoiceId` to match the new PDF logo size and improve brand presence in mobile inboxes. Monogram fallback (text-[18px]) and app-name text (text-[15px]) scaled in proportion.\n- **Skeleton loaders** — replaced bare \"Loading invoice…\" / \"Loading…\" centered text with proper layout-matching skeletons (header chrome + hero card + line-item rows). Reduces perceived latency on slow connections and feels consistent with the rest of the app where every list/detail view already uses skeletons.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-public-surface-skeleton-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"71079689-320e-4e86-974d-6b5642fba9be","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"recruit-page-inline-shimmer","type":"changed","scope":"recruitment","summary":"/recruitment/applications/$id/recruit — inline shimmer instead of \"Loading…\" text in dl rows.","body":"Three definition-list rows on the recruit-to-hire page (HRM position, Reporting manager, Shift) showed a literal \"Loading…\" string while the secondary lookup query was in flight. Replaced with a small inline shimmer bar matching the field width so the row preview looks closer to its loaded state.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-recruit-page-inline-shimmer.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5691fcb9-8014-44d4-b142-4fc3d758631e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-approvals-polish","type":"changed","scope":"sales","summary":"/sales/approvals polish — pending-total chips, \"waiting Nd\" badges, skeleton loader.","body":"Polish pass on the invoice approval queue:\n\n- **Pending-total chips** in the header — one chip per distinct currency, each tagged with the sum across the pending queue. Operators can see at a glance \"I'm sitting on $42k of USD and €18k of EUR pending\" without opening every row.\n- **\"Waiting Nd\" pill per row** — amber for 1–2 days, rose for 3+ days (stale). Helps the operator triage by age, not just by amount. Hidden for sub-day waits to keep the row calm.\n- **Skeleton loader** while `isLoading` — three pulsing placeholder rows instead of an empty container.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-sales-approvals-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c59a0439-f59b-49c9-a5e4-0dbe6694aad5","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-list-skeleton-rows","type":"changed","scope":"sales","summary":"/sales/invoices + /sales/quotations — 5-row skeleton loader while list is fetching.","body":"The two highest-traffic sales list pages — invoices and quotations — showed an empty container during the initial fetch and only flipped to either the empty-state card or the populated list once data arrived. On slow connections the empty container looked like an empty state. Both now render a 5-row skeleton (status pill + number + title + amount) so the user immediately sees the right shape.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-sales-list-skeleton-rows.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"59e1537f-fe4d-42ed-9ead-89d61f375c20","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-more-list-skeletons","type":"changed","scope":"sales","summary":"/sales/payments + /sales/subscriptions + /sales/credit-notes — list skeletons during isLoading.","body":"Same skeleton-loader pattern landed on the remaining sales list pages so the entire `/sales/*` namespace settles in place during the initial fetch:\n\n- **/sales/payments** — 5 placeholder rows with date + payee + amount columns\n- **/sales/subscriptions** — 4 placeholder rows with name + status pill + cycle total\n- **/sales/credit-notes** — 4 placeholder rows with status + number + reason + total","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-sales-more-list-skeletons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"9bf1b635-0c68-4451-873c-db38b6bb5c9c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-products-polish","type":"fixed","scope":"sales","summary":"/sales/products — currency-aware prices, inactive badge, skeleton loader.","body":"Polish + correctness pass on the products & services catalog:\n\n- **Currency-aware prices** (bug fix) — the per-row price was rendered as `$X.XX` regardless of the product's own currency. A PKR-denominated retainer now correctly renders `PKR 65,000.00` instead of misleadingly showing `$65,000.00`. Switched from the legacy `formatCents` helper to `formatMoney(cents, currency)`.\n- **Inactive badge** — products with `isActive=false` no longer rely on the opacity-60 dimming alone; they now carry an explicit \"inactive\" pill. Color is never the only signal.\n- **Skeleton loader** — four placeholder rows during `isLoading` instead of an empty container.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-sales-products-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ec1eb331-fc88-434b-9d1a-3dc4d9c6dc3b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-small-polish-batch","type":"changed","scope":"sales","summary":"/sales/tax-rates skeleton + /sales/settings honest currency label.","body":"Small polish batch on two sales settings surfaces:\n\n- **/sales/tax-rates** — three-row skeleton loader during `isLoading` instead of an empty container.\n- **/sales/settings — currency label** — replaced the misleading `$` prefix on the approval-threshold + late-fee-cap fields with the actual org-default currency code (PKR / EUR / etc.) shown as a token next to the input. Removed the awkward \"(in org default currency)\" parenthetical from the label. A PKR-currency org no longer sees `$` next to fields they enter in rupees.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-sales-small-polish-batch.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f0492cf8-7345-4019-b6d1-ede8c67358e7","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-subscriptions-list-currency","type":"fixed","scope":"sales","summary":"/sales/subscriptions — header chips + cycle-total footer now use formatMoney.","body":"Two remaining `formatCents` callsites on the subscriptions list page were rendering as `1,234.56 USD` (digits-then-code) instead of the canonical `USD 1,234.56` shape used elsewhere. Both swapped to `formatMoney(cents, currency)`:\n\n- **Header per-currency totals chip** — when an org has subscriptions across multiple currencies, the header summary now reads `USD 4,200.00 · EUR 1,800.00` matching the rest of the app.\n- **Cycle-total footer in the create/edit sheet** — same shape consistency.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-sales-subscriptions-list-currency.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"4b6c1636-5d43-442c-964d-90b36a4668e5","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"support-skeleton-loaders","type":"changed","scope":"support","summary":"/support inbox + /support/$ticketId detail + audit log — skeleton loaders.","body":"Three loading states in the support surface replaced with layout-matching skeletons:\n\n- **/support inbox** — 6 placeholder rows (status pill + subject preview + relative time) instead of \"Loading inbox…\" sentence.\n- **/support/$ticketId** — full page skeleton (breadcrumb + title + status + side-by-side body + audit panel) instead of \"Loading ticket…\" centered text.\n- **Audit log side panel** — 4 placeholder cards instead of \"Loading…\" text.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-29-support-skeleton-loaders.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"6be037ab-8cda-4a0a-bd2e-bcc96fb3e085","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"app-icon-enhancements","type":"changed","scope":"web","summary":"Refreshed the app's navigation icons — clearer module glyphs and richer active tiles.","body":"Polished the Phosphor-based icon language used across the app chrome:\n\n- **Clearer module glyphs.** Swapped a few weak/ambiguous rail icons for\n  more recognizable ones: CRM now uses an address book (the contacts\n  system of record, distinct from the Clients icon beside it), Payroll\n  uses banknotes, and Accounting uses a bank — so the finance-adjacent\n  modules read at a glance instead of blurring together.\n- **Richer active tiles.** The selected module's icon now renders as a\n  small \"app-icon\": a soft vertical gradient in the module hue, a hairline\n  edge, a faint lift, and a subtle drop-shadow on the glyph.\n- **Consistent weights & sizes.** Documented and applied the chrome icon\n  convention — feature glyphs are duotone when idle and filled when active;\n  small affordances (search, chevrons, hamburger) are bold — and squared up\n  the topbar action-group icon sizes.\n- **No drift.** The coming-soon module pages now render their icon from the\n  module registry instead of a hardcoded glyph (Accounting was showing a\n  different icon than its rail tile), so a module's icon is defined in\n  exactly one place.\n- **Accurate docs.** `DESIGN.md` and the UI rule now state the app's icon\n  set is Phosphor (the marketing site uses Lucide), preventing a mixed set\n  from creeping back in.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-app-icon-enhancements.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"460f481b-8a1a-49cc-958d-eade5e935b13","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"app-shell-polish-module-tint-reduced-motion","type":"changed","scope":"web","summary":"Polished the app shell — module-tinted active nav, reduced-motion support, and a denser wide-screen layout.","body":"A low-risk polish pass over the app chrome (rail, module sidebar, page body):\n\n- **Module-tinted active navigation.** The active rail tile and the active\n  sub-nav row now carry the *module's own* accent (CRM blue, HRM amber, …)\n  as a soft rounded tint, instead of a generic square in the global accent.\n  Module identity now reads consistently from the rail through the sidebar.\n- **Respects \"reduce motion\" everywhere.** A single app-wide motion\n  contract now makes every animated surface — the per-route page\n  transition, the rail's active-edge slide and unread-badge pops, the\n  mobile navigation drawer, the command center, the clock-widget popover,\n  PWA toasts, and every modal/sheet — honour the OS \"reduce motion\"\n  setting, snapping slides/scales/springs to a quiet cross-fade for\n  vestibular-sensitive users.\n- **More room on large screens.** Trimmed the oversized page gutters at the\n  `xl`/`2xl` breakpoints so wide monitors show more content without feeling\n  cramped.\n- **Cleaner chrome surfaces.** The rail and module sidebar now share one\n  glass treatment (they're peers), and the topbar clock no longer renders a\n  pill inside a pill — small consistency fixes that make the chrome read as\n  one cohesive system.\n- **Consistent notification badges.** Every unread \"count circle\" (chat\n  rail, support rail, the topbar bell) now renders through one shared\n  component — identical size, weight, ring, and motion. The chat badge,\n  which was quietly using an off-palette hardcoded red, now uses the\n  design-system danger colour like the rest.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-app-shell-polish-module-tint-reduced-motion.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"efe29faa-238d-4d57-b437-856d5312d3e1","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-documents-expiring-signal","type":"added","scope":"clients","summary":"Client health now flags agreement documents expiring within 30 days.","body":"`clients.client.health` gained a `documents_expiring` signal and an `expiringDocumentCount` field: it counts confirmed agreement documents (NDA/MSA/SOW/signed-quote/…) whose `expiresAt` falls within the next 30 days and nudges the health score to yellow so the account team renews the paperwork before coverage lapses. The client detail health panel labels the new signal — and, while there, the previously-unlabelled `projects_stalled` signal — instead of showing the raw key.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-documents-expiring-signal.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"aed134d0-14a4-490e-9c8a-ce09d067a1d0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"banner-tint-fix","type":"fixed","scope":"web","summary":"Status banners (verify email, billing, incidents) now show their colour instead of rendering grey.","body":"Several full-width status banners in the app reached for CSS variables\n(`--danger`, `--warning`) that don't exist in the token set — only\n`--color-danger-500` / `--color-warning-500` do. The `color-mix()` tint and\nthe icon colour therefore silently failed, so these banners rendered with a\ngrey border, no background tint, and a grey icon — losing the red/amber\nurgency they're meant to convey:\n\n- \"Verify your email\" / \"Finish setting up your account\"\n- Subscription past-due / trial-ending\n- Critical / major platform incident\n\nAll of these now render in the correct tone. They also share one\n`ChromeBanner` strip component instead of each re-declaring the\n`border-y` + tinted-background formula, so the banner stack reads as one\nconsistent system and can't drift again.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-banner-tint-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"dcecb448-f236-4d1c-bea0-9ceb58f57109","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-ai-brief-consumer","type":"added","scope":"clients","summary":"Client detail surfaces an on-demand \"AI brief\" panel (plan G4).","body":"The `clients.client.ai_brief` action — a structured per-client briefing (headline + read-aloud summary + key facts + verb-led next steps) — existed and was registered but had no UI consumer. Added an \"AI brief\" button to the client detail header that opens a modal rendering the brief on demand (lazy fetch, 60s stale). Operators can now scan open balance, overdue exposure, MRR, renewals, support load, and suggested next steps before a call, without leaving the client page. Closes plan item G4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-ai-brief-consumer.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"868d520e-9536-44df-8c57-8cd45cad9582","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-auto-promote-emits-lifecycle","type":"fixed","scope":"clients","summary":"First-invoice auto-promotion now emits lifecycle_changed — welcome email + audit fire.","body":"When a `prospect` client's first invoice is issued, the clients module auto-promotes it to `customer`. That path previously did a bare `db.update` and only logged — it never emitted `clients.client.lifecycle_changed`, so the **customer welcome email never fired**, no internal notification was sent, and no `audit_log` row was written (only the manual lifecycle change emitted correctly).\n\nThe auto-promote subscriber now routes through the `clients.client.set_lifecycle` action via a system context, so the standard event fires with `origin: 'invoice_issued'` (a new optional field on the lifecycle input that lets the audit log distinguish auto-promotions from operator-driven changes). The welcome email, notification, and audit row now all fire on first invoice.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-auto-promote-emits-lifecycle.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"6a0f3f46-8c0c-4583-aabd-4f7db064ec07","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-bulk-owner-tag","type":"added","scope":"clients","summary":"/clients bulk action bar gains assign-owner + add/remove-tag (plan H1).","body":"The `/clients` list already supported bulk lifecycle change + CSV export over a multi-select. Added the two remaining H1 bulk operations:\n\n- **Assign owner** — a `MemberPicker` in the bulk bar reassigns every selected client's owner in one sweep (iterates `clients.client.update`).\n- **Add / remove tag** — a tag input + Add/Remove buttons merge or strip a tag across the selected rows (computed against each row's current tags, written back via `clients.client.update`; no-op writes skipped).\n\nBoth follow the existing client-side-iteration pattern (small batches, per-row failure counted, partial-success toast).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-bulk-owner-tag.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d3b91dbe-124c-4171-9c18-ffe1f1650d6a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-cmd-e-shortcut","type":"added","scope":"clients","summary":"Cmd/Ctrl+E opens the edit sheet for the focused client row (plan H3).","body":"Completes the `/clients` keyboard-shortcut set (alongside `/` focus-search, `j`/`k` navigate, `Enter` open, `x` select, `Esc` clear): **Cmd/Ctrl+E** opens the edit sheet for the `j`/`k`-focused row. Lifecycle is one of the fields editable in that sheet, so it doubles as the change-lifecycle affordance (no separate Cmd+L target exists on the list — lifecycle is also changeable via the bulk action bar).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-cmd-e-shortcut.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5038c78c-dfac-43b5-b327-b86178df1571","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-delete-policy-and-deeplink","type":"fixed","scope":"clients","summary":"clients.client.delete now uses the delete permission; engagement emails link to the real route.","body":"Two correctness fixes surfaced by the gap analysis:\n\n- **`clients.client.delete` was gated by `clientWritePolicy`**, not the dedicated `clientDeletePolicy` (`clients:client:delete`). Any actor with update scope could soft-delete a client, and the delete permission was dead code. Now wired to `clientDeletePolicy`.\n- **Engagement emails deep-linked to `/clients/$companyId/engagements/$id`** — a route that doesn't exist (the actual engagement detail is `/engagements/$id`), so every \"engagement created/completed/…\" email 404'd on click. Fixed all four handlers to point at `/engagements/$id`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-delete-policy-and-deeplink.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"07023896-8ddd-4ba0-812b-440e82d58e1e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-documents-actions","type":"added","scope":"clients","summary":"Client-document actions — presigned upload, confirm, list, download, delete (Phase E).","body":"The action layer for client documents (on the `client_documents` foundation), mirroring the projects attachment pattern:\n\n- **`clients.document.create_upload_url`** — validates content-type + size, verifies the company (and engagement, if scoped) in-org, inserts a pending row, and mints a short-lived presigned PUT (+ a download URL) so the browser uploads direct to object storage.\n- **`clients.document.confirm_upload`** — stamps `uploaded_at` after the PUT succeeds (idempotent).\n- **`clients.document.list`** — org + company scoped, soft-delete aware, optional engagement filter + expired-exclusion; never returns the raw storage key.\n- **`clients.document.get_download_url`** — re-signs a GET URL (org-prefixed storage-key check).\n- **`clients.document.delete`** — soft-delete (`dangerous: true`).\n\nAdds `clientDocumentCreated` / `clientDocumentDeleted` events, the three document policies, and 27 contract tests (135 clients tests green). The Documents UI tab + expiry cron follow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-documents-actions.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ddf9725f-4c38-4621-81c1-e4eef067fc95","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-documents-foundation","type":"added","scope":"clients","summary":"Client-documents foundation — client_documents table, migration, and permissions (Phase E).","body":"Foundation for client document storage (plan Phase E — NDAs, MSAs, SOWs, signed quotations):\n\n- **`client_documents` table** (migration `0202_0203`) — org-scoped, attaches to a client (`company_id`) and optionally a specific engagement (`engagement_id`, SET NULL on engagement delete so the file survives as a client-level doc). Carries `kind` (nda/msa/sow/signed_quotation/other, CHECK-constrained), `storage_key`/`mime_type`/`size_bytes`, agreement lifecycle dates (`signed_at`/`effective_from`/`expires_at`), and a two-step-upload `uploaded_at` stamp. A partial `expires_at` index powers the upcoming expiry cron.\n- **Permissions** — `clients:document:{read,create,delete}` added to the catalog with descriptions, and granted to the clients management blueprint (owners/admins get them via `clients:admin`).\n\nActions (presigned upload / confirm / list / download / delete), the Documents UI tab, and the expiry cron land in follow-up commits on this foundation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-documents-foundation.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"50f2a2ac-20f5-4abc-a3ef-2dde455be184","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-documents-tab","type":"added","scope":"clients","summary":"Documents tab on the client detail page — upload, list, download, and delete agreement files.","body":"The operator-facing layer for client documents (Phase E), on top of the `client_documents` table + actions:\n\n- A **Documents** tab on the client detail page listing every agreement file (NDAs, MSAs, SOWs, signed quotations, other) newest-first, with a kind badge, file size, effective/expiry dates, and \"upload pending\" / \"expired\" status chips.\n- An inline **upload** control — pick a file, choose the kind, set an optional display name + expiry date — that runs the direct-to-storage presigned PUT (`create_upload_url` → browser PUT → `confirm_upload`).\n- **Download** (re-signs a fresh URL on click) and **delete** (soft-delete, confirm-gated) per row, both gated behind the client's edit permission.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-documents-tab.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"92c892eb-316d-4ca0-b5eb-00e92beba227","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-engagement-plan-limit","type":"added","scope":"clients","summary":"clients.engagement.create now enforces a per-plan engagements cap (Phase I parity).","body":"`clients.client.create` already enforced a `clients_limit` plan cap, but `clients.engagement.create` had no equivalent gate. Added an `engagements_limit` feature to the plan-feature catalog (Free 10 / Starter 50 / Business + Enterprise unlimited) and a `checkEngagementsQuota` gate on engagement creation that mirrors the clients gate: best-effort (no-op when the saas-limits action isn't registered or the plan reports unlimited), returns `quota_exceeded` with an upgrade prompt when the org has hit its cap. Completes plan item I for engagements.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-engagement-plan-limit.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"801c2f5b-c182-42f3-b00d-6c0b80ee460d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-engagement-renew","type":"added","scope":"clients","summary":"New clients.engagement.renew action + a working \"Renew\" button on /clients/renewals.","body":"The `/clients/renewals` queue advertised a \"Mark renewed\" action but had no backing action — the button was never wired (only Quote + Churn worked). Added `clients.engagement.renew({ id, periodMonths = 12 })`:\n\n- Bumps the engagement's `renewalDate` forward by `periodMonths` (default 12 = annual term), anchored to the later of the current renewal date and today so a lapsed renewal lands in the future.\n- Emits `clients.engagement.updated` with `changedKeys: ['renewalDate']`.\n- Returns the new renewal date.\n\nWired a \"Renew\" button into each row on `/clients/renewals` (between Quote and Churn). Added 5 contract tests (policy-denial, validation, not-found, the 12-month date bump + event emission).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-engagement-renew.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"4efe5919-c8c6-481c-a1d4-7060e657456e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-engagement-status-activity","type":"added","scope":"clients","summary":"Engagement status changes now post a durable activity-timeline note (plan B4).","body":"When an engagement transitions status (active → paused → completed → churned), the client detail Activity tab previously only reflected the engagement's *current* state via a live join — it couldn't show the transition history with per-change timestamps.\n\nA new subscriber on `clients.engagement.status_changed` now writes a durable CRM activity note (\"Engagement '<name>': active → paused\", with the reason in the body) via `crm.activity.create`, so the timeline records each transition at the moment it happened. Closes plan item B4 of the clients overhaul.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-engagement-status-activity.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7f0e4185-957d-40f3-9a4a-d69c49cfa168","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-health-projects-signal","type":"changed","scope":"clients","summary":"Client health score now factors in stalled (paused/cancelled) delivery projects (plan D1).","body":"`clients.client.health` aggregated billing, engagements, and support but ignored delivery projects — so a customer with paused or cancelled work could still show green. Added a cross-module read of the projects table (same defensive try/catch pattern as the support read) that counts active (planning/active) vs stalled (paused/cancelled) projects for the client. A new `projects_stalled` signal nudges the score to yellow when stalled work exists, and the output gains `activeProjectCount` / `stalledProjectCount` so the detail KPI strip + AI brief can surface delivery state. Closes the project-signal half of plan D1 (NPS remains deferred — needs a survey table).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-health-projects-signal.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c2c2f5ec-6d35-4ef1-8d4c-f205014eb1cc","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-list-view-presets","type":"added","scope":"clients","summary":"/clients gains funnel / receivables / at-risk view-mode chips (plan A1).","body":"Completes the Phase-A unified-surface plan with the `view=` preset enum on `clients.client.list` + a chip strip on `/clients` (All · Pipeline · Receivables · At risk). Each preset is a **cursor-safe server-side filter** (no aggregate sort, so pagination still holds):\n\n- **Pipeline (`funnel`)** — `lifecycleStage = prospect`.\n- **Receivables** — customers with an open invoice balance (correlated `EXISTS` over issued/partial/overdue invoices where `total > paid`).\n- **At risk** — customers with no activity in 30+ days (or never).\n\nThe chip writes `?view=` to the URL (TanStack search param, bookmarkable). This is the consolidation the old `/sales/clients` (receivables) and `/crm/companies` (funnel) surfaces hinted at, now as facets of the single `/clients` hub. +2 contract tests (108 clients tests green).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-list-view-presets.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"5965310c-735e-40d5-aded-32e1af140d5a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-portal-access","type":"added","scope":"sales","summary":"Customer portal links can finally be minted — a Portal-access modal on the client detail.","body":"The public customer portal (`/portal/$companyId?token=`) was unreachable: `sales.client.create_portal_link` / `list_portal_links` / `revoke_portal_link` existed but had **no UI**, so nothing could mint the token the route requires. Added a **Portal access** button on the client detail header that opens a management modal:\n\n- **Mint** a token-gated link (optional label) → the full URL is shown once with a Copy button.\n- **List** active links with view-count + expiry + a default badge; copy the long-lived default link's URL inline (its token is safe to re-display).\n- **Revoke** any link.\n\nComposes the three existing sales actions from the clients route (no backend change), making the customer self-service ledger portal usable end-to-end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-portal-access.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"1a5a18e4-43e4-48f5-9373-4dbccc265de5","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"clients-statement-tab","type":"added","scope":"sales","summary":"Statement-of-account is now reachable — a Statement tab on the client detail (ledger + PDF + email).","body":"`sales.client.statement_of_account` (+ `.render_pdf` + `.send_email`) were fully implemented but had **zero UI callers** — the statement of account was unreachable. Added a **Statement** tab to the client detail (`/clients/$id`) that surfaces it:\n\n- A summary strip (opening balance · invoiced · paid · closing balance) in the client's currency.\n- The dated ledger — every invoice (charge), payment, and credit-note application with a running balance, payments/credits shown in green.\n- **Download PDF** (`render_pdf` → branded statement PDF) and **Email statement** (`send_email` → PDF attached to the billing contact).\n\nLazily fetched (only when the tab opens). No new backend — composes the three existing sales actions from the clients route.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-clients-statement-tab.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"dffd3390-23ce-4958-9fb4-4c165e376015","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"compat-token-aliases","type":"fixed","scope":"web","summary":"More undefined tokens fixed — accent buttons, error text, modal shadows and chip radii now render.","body":"Second pass of the design-token audit. Another batch of variables was\nreferenced across the app but never defined, so each resolved to a\nguaranteed-invalid value and silently broke:\n\n- `--bg` / `--fg` / `--border` (layout), `--fg-danger` (error text + required\n  asterisks), `--ring-default` (focus ring), `--bg-elevated` / `--bg-input`,\n  `--border-emphasis` / `--border-focus` / `--border-hover` — fell back to the\n  inherited colour.\n- `--accent-default` (solid accent buttons + accent text/links), `--accent-fg`\n  / `--accent-on` / `--accent-foreground` / `--accent-primary-fg` (text on\n  accent), `--accent-subtle` — rendered colourless.\n- `--shadow-xl` — modal/dialog shadows in several settings panels were\n  dropped entirely, so the dialogs floated with no elevation.\n- `--radius-xs` — small-radius chips, tabs and code spans rendered with sharp\n  corners.\n\nAll are now defined as aliases over the canonical semantic tokens (and the\nnew `--radius-xs` / `--shadow-xl` scale members, with a dark-mode shadow\nvariant), so every existing call site renders correctly with no call-site\nchanges.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-compat-token-aliases.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"bce5016b-b08b-4c58-9c77-86c222cfc36b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-admin-nav","type":"added","scope":"web","summary":"Payment providers are now reachable from the nav — Settings → Payments (tenant) and SaaS → Billing → Payments (root).","body":"# Payments admin is now discoverable in the nav\n\nThe payment-provider admin surfaces existed but had no nav entry — they were reachable only by typing the URL. Both are now linked:\n\n- **Settings → Workspace → Payments** (`/settings/payments`) — where a tenant admin connects payment providers (Stripe, Paddle, Razorpay, PayPal, manual), sets routing rules, and reviews charges + disputes for the org's own charges. Sits next to Email, the analogous per-org fabric.\n- **SaaS → Billing → Payments** (`/saas/platform/payments`, root only) — the platform-tenant gateway that bills tenants for SaaS subscriptions and powers marketing-site + signup plan purchases.\n\n## Also\n\n- The `/saas/platform/payments` route now **hard-denies non-root users** in the component (mirroring the other `/saas/platform/*` routes). Its doc-comment previously claimed a loader gate that didn't exist; the gate is now real, so a typed URL / stale bookmark from a non-root user shows a \"Root only\" panel instead of the admin surface.\n\n## Follow-up — resolved\n\nThe original concern (the platform console reused the tenant component, which scoped to the active org) is fixed in the sibling entry `payments-platform-tenant-scoping`: the payments admin actions now take an `orgId` override gated to `platform:payment_gateway:manage`, and `/saas/platform/payments` pins it to the platform tenant.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-admin-nav.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7b6101cf-1813-418e-ab8e-a941eb1d96ac","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connect-account-webhook","type":"added","scope":"payments","summary":"Stripe Connect onboarding status now updates in real time via the account.updated webhook.","body":"# Connect status updates in real time\n\nWhen a tenant finishes (or progresses through) Stripe Connect onboarding, Stripe fires an `account.updated` webhook. Helios now handles it: the connected-account row's capability flags (charges/payouts enabled, details submitted), outstanding requirements, and disabled reason are refreshed automatically — so the \"Accept payments with Stripe\" card flips to **Active** the moment Stripe enables the account, without the operator clicking refresh.\n\nThe event is verified at the existing signed-webhook edge (fail-closed), parsed by the Stripe adapter (`account.updated` → neutral `account.updated`), and applied by `stripe_account_id` (globally unique — the webhook is delivered to the platform provider, whose org differs from the tenant that owns the account). Operators configure Stripe to send `account.updated` to the platform payments webhook endpoint.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connect-account-webhook.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"3935e03b-5f16-4de1-b34d-2c7721a35d0d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"reduced-motion-status-dots","type":"fixed","scope":"web","summary":"Live status indicators now respect the OS \"reduce motion\" setting.","body":"The small \"live\" indicators in the app chrome animate with CSS keyframes,\nwhich the app-wide motion config (it governs JS animations only) didn't\ncover. They now honour `prefers-reduced-motion`:\n\n- The expanding \"ping\" ripples (the clocked-in dot, the voice-listening\n  pulse in the command center, the offline indicator in the installed-app\n  titlebar, the \"new version available\" toast) are hidden for reduced-motion\n  users — the crisp solid dot underneath remains, so the signal is intact.\n- The pulsing status dots (clock-in location check) and the auto-break grace\n  banner hold still instead of pulsing.\n\nSkeleton loaders are intentionally untouched (their opacity shimmer is a\nloading affordance, not vestibular motion).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-reduced-motion-status-dots.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a5eee2b4-42c5-4a12-b14d-a7701b9f9632","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connect-charges","type":"added","scope":"payments","summary":"Connect-onboarded tenants can now take payments — charges route through the platform on their connected account, with an optional platform fee.","body":"# Stripe Connect charges-on-behalf (Phase 2)\n\nA tenant that onboarded via Stripe Connect (and has no Stripe key of their own) can now actually **accept payments**. When a charge is requested for such a tenant, `payments.intent.create` falls back to the platform's Stripe provider and creates a **direct charge on the tenant's connected account** (`Stripe-Account` header), so the tenant is the merchant of record and funds settle to their bank — with the platform's configured **application fee** (`applicationFeeBps`) skimmed off.\n\nHow it stays safe + non-breaking:\n\n- **Fallback only.** Tenants with their own provider key are completely unaffected — Connect kicks in solely when routing finds no own provider *and* the org has a charges-enabled connected account.\n- **No cross-org reach.** The connected account is looked up by the calling org, so org A can only ever charge on org A's account.\n- **Webhook reconciliation fixed.** A Connect charge's intent carries the tenant's org but is processed by the platform provider, so the `payment_intent` webhook now matches the intent by `(provider_id, payment_intent_id)` and records the charge + emits events under the **intent's** org (the tenant) — not the platform.\n\nRefunds and disputes on connected-account charges (which need the same `Stripe-Account` routing) are the next slice; everything else (invoice paid-status, accounting) already keys off the existing `payments.charge.succeeded` event, which now fires correctly for Connect charges.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connect-charges.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"bcc2cea1-2c12-4f1b-9e00-294c99103427","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connect-disputes","type":"fixed","scope":"payments","summary":"Disputes (chargebacks) on Stripe Connect charges are now recorded against the correct tenant.","body":"# Disputes on Connect charges land on the right tenant\n\nThe `charge.dispute.created` webhook matched the disputed charge by `(provider_charge_id, org_id)`. For a Stripe Connect charge that fails for the platform org (the webhook is delivered to the platform provider, but the charge belongs to the tenant), so the dispute was silently dropped. It now matches by `(provider_id, provider_charge_id)` and records the dispute — and fires `payments.dispute.opened` (the owner-alert email) — under the **charge's own org** (the tenant).\n\nThis completes the Connect webhook-reconciliation set: charge succeeded/failed, refund succeeded, and now dispute created all attribute correctly to the tenant when the charge was taken on their connected account.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connect-disputes.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"dd01b2df-1f37-4eeb-8e7f-6182271282e0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connect-fee-config","type":"added","scope":"payments","summary":"Root operators can set the platform application fee charged on a tenant's Stripe Connect payments.","body":"# Configure the Stripe Connect platform fee\n\nPhase 2 plumbed the application fee end-to-end, but nothing set it — so Connect charges defaulted to a 0% platform cut. New action `payments.connect.set_fee` closes that: a root operator sets the fee (in basis points, 0–2000 = max 20%) on a tenant's connected account, and it's applied to that tenant's next Connect charge.\n\nIt's **root only** (`platform:payment_gateway:manage`) — the platform decides the fee a tenant pays; a tenant can never set its own. The fee can be set/changed at any time and takes effect on the next charge (read from the connected-account row at `intent.create`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connect-fee-config.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"1d705f46-78e0-4ab7-9b84-eb2d0ee5f32a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connect-fee-ui","type":"added","scope":"payments","summary":"A platform admin can set each tenant's Stripe Connect fee from the console, and tenants see the fee they pay.","body":"# Connect fee — now visible + configurable in the UI\n\nThe Connect platform fee was action/MCP-only; it now has a UI:\n\n- **Platform console** (`/saas/platform/payments`) gains a **Connected accounts** card listing every tenant that onboarded via Stripe Connect — org, account status, and an inline percentage editor that saves the platform fee (0–20%) per tenant via `payments.connect.set_fee`. Backed by a new root-only `payments.connect.list` action.\n- **Tenant card** (`/settings/payments`) now shows \"Platform fee: X% of each payment\" once a fee is set, so the tenant knows what they're charged.\n\n`payments.connect.status` now returns the configured fee for display.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connect-fee-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"0cefd139-cfec-42c2-a791-6cdb8d0eea6e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connect-refunds","type":"added","scope":"payments","summary":"Charges taken on a tenant's Stripe Connect account can now be refunded.","body":"# Refunds for Stripe Connect charges\n\nThe charge half of Stripe Connect shipped in Phase 2; this completes the loop. `payments.refund.create` now detects when a charge was made on a tenant's connected account (the charge's provider is the platform provider and the org has a connected account under it) and issues the refund **on that connected account** (`Stripe-Account`), so Stripe can find the original charge. The `refund.succeeded` webhook reconciles by `(provider_id, provider_refund_id)` and records the refund under the refund's own org — mirroring the charge-path fix — so a Connect refund settles correctly even though the webhook is delivered to the platform provider.\n\nOwn-provider tenants are unaffected (no connected account → normal refund). Dispute handling for connected-account charges is the remaining Connect follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connect-refunds.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f84adae4-0dfc-4896-aaa4-3866cc6c9860","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-connection-ux","type":"changed","scope":"payments","summary":"Redesigned the connect-a-provider experience — provider cards, inline connection testing, password reveal, copy-webhook, and proper currency picker.","body":"# A polished provider connection experience\n\nConnecting a payment provider at Settings → Payments (and the platform console) is now a guided, high-confidence flow instead of a bare dropdown + plain inputs:\n\n- **Provider picker** — a card grid showing each gateway with capability chips (hosted checkout, embeddable, full/partial refunds, disputes, supported currencies). Stripe and PayPal lead as the primary providers.\n- **Inline connection test** — a \"Test connection\" button validates the credentials *before* saving (a dry-run against the entered config when connecting, or the stored config when editing) and shows a per-step ✓/✗ checklist with the provider's own reasons.\n- **Secret reveal** — password/API-key fields have a show/hide toggle so you can verify what you pasted; on edit they keep \"leave blank to keep current\".\n- **Required-field validation** — missing required fields are flagged inline on save instead of failing server-side.\n- **Copy webhook URL** — one-click copy of the provider's webhook endpoint, with the full provider-id URL shown after connecting.\n- **Proper currency selection** — the default-currency field now uses the standard currency picker instead of a free-text box.\n- **Toasts everywhere** — connect / update / remove / test now give clear success + error feedback.\n\nAll built on the design-system primitives (Switch, Field, IconButton, toast) with full keyboard + screen-reader labelling.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-connection-ux.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c831c516-3659-447e-83bf-f662a5f66985","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-dispute-owner-alert","type":"added","scope":"payments","summary":"Org owners + admins are now alerted by email the moment a payment is disputed.","body":"# Payments — owners are alerted on disputes\n\n`payments.dispute.opened` was emitted but had **zero subscribers** — a chargeback (a time-sensitive money event with an evidence deadline) produced no notification at all. Now it alerts the people who can act on it.\n\n## What's new\n\n- **Dispute owner-alert subscriber** ([email-on-dispute-opened.ts](modules/payments/src/jobs/email-on-dispute-opened.ts)) — on `payments.dispute.opened`, resolves the org's owners + admins (`memberships.role IN ('owner','admin')` → `users.email`) and sends each an alert via the unified email fabric. The provider name is resolved from the charge; the \"review the dispute\" link points at the tenant payments admin (`/settings/payments`) or the platform-tier surface for platform-tenant disputes. Idempotent per recipient (`payments.dispute.opened.<disputeId>.<email>`), so webhook retries collapse to one alert.\n- **`payments.dispute.opened` email template + flow** — operator-facing, leads with the disputed amount + the evidence deadline (the deadline is what makes disputes urgent). White-label safe (brand + support email via auto-injected template vars).\n\n## Note\n\nThe subscriber is wired and the template/flow pass the CI sync check, but it only fires once the payments webhook maps provider dispute events to `payments.dispute.opened` (dispute ingestion lands with the disputes phase). Shipping the alert path now means dispute notifications work the moment ingestion is enabled.\n\nPart of the clients/sales gap remediation: [docs/plans/CLIENTS_SALES_GAP_ANALYSIS.md](docs/plans/CLIENTS_SALES_GAP_ANALYSIS.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-dispute-owner-alert.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"63101b0a-dcd3-4345-b390-c2e310447a4b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-platform-tenant-scoping","type":"fixed","scope":"payments","summary":"/saas/platform/payments now manages the platform-tenant providers regardless of the operator's active org.","body":"# Platform payments console manages the platform tenant, not the active org\n\n`/saas/platform/payments` reused the tenant `PaymentsAdminPage` component, whose actions scope to the request's active org (`ctx.orgId`). So a root operator whose active org was a real tenant saw and edited *that tenant's* providers there — not the platform-tenant fabric (`org_id = 00000000-…`) that actually bills SaaS subscriptions and powers marketing/signup plan purchases.\n\nNow the platform console pins itself to the platform tenant:\n\n- The payments admin actions (`provider.{list,create,update,delete,test}`, `routing.{list,upsert,delete,preview}`, `charge.{list,get}`, `dispute.list`) accept an optional `orgId` override.\n- The override is honoured **only** when the caller holds the root-only `platform:payment_gateway:manage` permission (server-resolved via a new `resolvePaymentsAdminOrgId` helper). A tenant admin passing `orgId` is silently pinned back to their own org — they can never read or mutate another tenant's payment config.\n- `/saas/platform/payments` passes `orgId = 00000000-…`; `/settings/payments` passes nothing and behaves exactly as before.\n\nThe React query cache is segmented by the override (`['payments.provider.list', orgId ?? 'self']`) so the two views never collide.\n\nThe cross-org gate is covered by `modules/payments/src/lib/platform-org.test.ts` (5 cases: tenant admin pinned, platform-grant honoured, root honoured, undefined/null/empty fallbacks).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-platform-tenant-scoping.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"59b749fc-42a2-40f1-b995-e052d344201a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-provider-expansion","type":"added","scope":"payments","summary":"Added 14 payment providers (Adyen, Checkout.com, Square, Authorize.Net, GoCardless, Airwallex, Mercado Pago, dLocal, Paystack, Flutterwave, Xendit, Cashfree, Midtrans, Coinbase Commerce).","body":"# 14 new payment providers — 5 → 19 connectable gateways\n\nThe payment gateway now ships adapters for a global spread of processors, each connectable from Settings → Payments (or the platform console). The picker auto-renders each one's connection form from its manifest; the webhook edge, routing engine, and admin redactor pick every new provider up automatically from the catalog.\n\nNewly connectable:\n\n- **Adyen** — global enterprise (cards + 100+ local methods)\n- **Checkout.com** — global cards + APMs\n- **Square** — in-person + online (US, CA, UK, AU, JP)\n- **Authorize.Net** — US/Canada cards (Accept Hosted)\n- **GoCardless** — bank debit (ACH / BACS / SEPA / Autogiro)\n- **Airwallex** — global cards + local methods\n- **Mercado Pago** — Latin America (cards, Pix, boleto, wallet)\n- **dLocal** — emerging-market cross-border (LatAm, Africa, Asia)\n- **Paystack** — Nigeria, Ghana, South Africa, Kenya\n- **Flutterwave** — pan-African + global\n- **Xendit** — Southeast Asia (Indonesia, Philippines)\n- **Cashfree** — India (UPI, cards, netbanking, wallets)\n- **Midtrans** — Indonesia (Snap checkout)\n- **Coinbase Commerce** — cryptocurrency\n\nEach provider is a single self-contained adapter file implementing the existing plugin contract (config schema + form manifest + connection test + webhook signature verification + event parsing + hosted-checkout intent + refund), proving the \"add a provider in one file\" design. Every adapter's webhook signature verification uses a constant-time compare and **fails closed** — a missing or mismatched signature is rejected, never accepted. A new catalog-wide test asserts that fail-closed behaviour, plus no-duplicate-ids and contract conformance, for every provider (current and future).\n\nRecurring/subscription billing for these providers remains a Phase 2 item (each correctly reports `subscriptions: false`); Phase 1 covers one-shot hosted checkout + refunds + webhook ingestion.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-provider-expansion.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"fa0e959f-b184-4c6a-af3b-c826fc3c88a2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-provider-icons","type":"changed","scope":"payments","summary":"Payment providers now show a category icon in the connect picker and the connected-providers table.","body":"# Provider icons\n\nThe provider picker cards and the connected-providers table now lead with a tinted category icon — card, wallet (PayPal), bank (GoCardless direct debit), crypto (Coinbase Commerce), or receipt (Manual) — making providers easier to scan and recognise at a glance. Icons come from the app's Phosphor set (no bundled third-party brand SVGs / licensing), with tints drawn from the brand token + the standard palette.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-provider-icons.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"91f5d83d-53ad-4678-8438-b15d3969a740","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-provider-picker-order","type":"changed","scope":"payments","summary":"Stripe and PayPal now lead the \"Connect provider\" picker as the primary providers.","body":"# Stripe + PayPal lead the provider picker\n\nThe connect-provider picker now orders Stripe and PayPal first, ahead of the other 17 gateways, reflecting their role as the primary providers. Ordering is driven by the descriptor seeder's display-order list.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-provider-picker-order.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"7768b4e5-668f-4f77-88f3-f2e88ac2bffa","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-sales-pay-path","type":"fixed","scope":"sales","summary":"The public invoice \"Pay\" button now actually starts checkout, and provider refunds flow back onto the invoice.","body":"# Sales — the invoice payment path is no longer a dead end\n\nTwo gaps from the payments state audit, both customer-/operator-visible:\n\n## Fixed\n\n- **The public invoice \"Pay\" button works.** On a shared invoice link (`/i/<invoiceId>?token=…`), clicking **Pay** previously only logged a `pay_clicked` activity — nothing happened. It now calls a new token-gated public action `sales.invoice.public.pay`, which mints a hosted checkout session for the open balance and redirects the recipient to `/pay/c/<sessionId>`. The amount, currency, and customer are read from the server-loaded invoice (never client input); paid/void invoices are refused. When no payment provider is configured to route the invoice, the button shows a clear \"online payment isn't available — pay externally\" message instead of failing silently.\n- **Provider-issued refunds flow back onto the invoice.** A new subscriber (`sales.invoice.refund-from-payments`) listens for `payments.refund.succeeded`, traces the charge → intent → invoice FK chain, and records the refund against the invoice via the existing `sales.payment.refund` recorder — flipping the invoice out of `paid` when its net paid drops. Previously only operator-initiated refunds touched invoice state; a refund issued on the payments side (e.g. via a provider dashboard → webhook) left the invoice stuck at `paid`. Idempotent on the provider refund id, so webhook retries are no-ops. No double-refund: `sales.payment.refund` is a pure ledger recorder (the money already moved on the provider side).\n\n## Still open\n\n- An operator-facing \"Send payment link\" button on the invoice detail page (the `sales.invoice.send_payment_link` action exists but is API-only today). Lower priority — operators can already share the link via the public-link flow.\n\nAssessment: [docs/plans/PAYMENT_GATEWAY_STATE_AUDIT.md](../../docs/plans/PAYMENT_GATEWAY_STATE_AUDIT.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-sales-pay-path.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"113c9062-16b7-4631-b9f5-a932236fa3c5","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-stripe-connect-ui","type":"added","scope":"payments","summary":"Tenants can now onboard with Stripe Connect from Settings → Payments — connect, see status, and open their Stripe dashboard.","body":"# \"Accept payments with Stripe\" — tenant onboarding card\n\nSettings → Payments now has an **Accept payments with Stripe** card that walks a tenant through Stripe Connect (Express) onboarding:\n\n- **Not connected** → a \"Connect with Stripe\" button starts hosted onboarding and redirects to Stripe; on return, status refreshes live.\n- **In progress** → an \"Action needed\" badge lists the outstanding requirements Stripe still needs, with a \"Continue onboarding\" button.\n- **Active** → a green \"Active\" badge, payout status, an \"Open Stripe dashboard\" button (Express login link), and a \"Refresh status\" action.\n\nThe card appears only in a tenant's own Settings → Payments (the platform console manages the Connect platform itself, not a sub-account). It's powered by the `payments.connect.{onboard,status,dashboard_link}` actions shipped in the Stripe Connect backend.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-stripe-connect-ui.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"86ba3fdf-aef6-42bc-9ceb-f9a3d4a32d6d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payments-stripe-connect","type":"added","scope":"payments","summary":"Stripe Connect (Express) — the platform can onboard tenant orgs as connected accounts (backend + onboarding actions).","body":"# Stripe Connect (Express) onboarding — backend\n\nThe platform can now onboard each tenant org as a Stripe **Express connected account**, so tenants accept card payments under their own Stripe account while the platform orchestrates onboarding (and, in a follow-up, takes an application fee on charges made on their behalf). This is distinct from a tenant pasting their own standalone Stripe key into Settings → Payments.\n\nShipped in this phase:\n\n- **Schema** — `payments_connected_accounts` (migration `0203_0204`): one row per tenant org, tracking the `acct_…` id, capability flags (charges/payouts enabled, details submitted), outstanding `requirements`, and an optional application-fee rate.\n- **Adapter** — the Stripe adapter gained Connect calls (raw `fetch`, no SDK): create Express account, mint hosted-onboarding Account Links, retrieve live status, and Express dashboard login links.\n- **Actions** — `payments.connect.onboard` (ensure account + return a fresh hosted-onboarding URL; idempotent), `payments.connect.status` (capability flags + requirements, optionally refreshed live from Stripe), and `payments.connect.dashboard_link` (Express dashboard login link). Gated to org admins; the platform Stripe credentials stay encrypted in the platform-tenant provider row.\n\nThe tenant onboarding UI and the `account.updated` webhook auto-refresh land in the next phase (status already refreshes live on demand). Account type was chosen as **Express** (Stripe-hosted onboarding, lowest compliance burden). Plan: `docs/plans/STRIPE_CONNECT_MODULE_PLAN.md`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payments-stripe-connect.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"66b6e5e9-8e03-4b8f-97a9-13c0f5948843","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payroll-employer-contribution-net-fix","type":"fixed","scope":"payroll","summary":"Employer-only contributions (e.g. a 401k match) no longer wrongly reduce an employee's net pay in the payroll calc engine.","body":"The pure payroll calculation engine processed every recurring deduction\nthrough the employee pre-tax / post-tax passes regardless of who funds\nit. So a deduction code marked `contributorSide: 'employer'` (an\nemployer-funded contribution like a 401k match or employer HSA) was\ncounted **twice** — once as an employer contribution (correct) and\nonce as an employee deduction that lowered the employee's net pay\n(wrong). On a run with an employer match, every affected employee was\nunderpaid by the match amount.\n\nThe fix mirrors the logic the employer-contribution pass already used:\nthe employee pre-tax (step 3) and post-tax (step 5) passes now skip\ncodes whose `contributorSide` is `'employer'`. `'employee'` and\n`'both'` codes are unchanged — a `'both'` code still reduces the\nemployee side **and** adds an employer contribution, which is correct.\n\nFound while adding the engine's first test suite. This ships a new\n`modules/payroll/src/calc/engine.test.ts` (16 cases) covering salary\nproration, hourly + 1.5x overtime, the three tax methods\n(flat / percent-with-cap / progressive brackets incl. overflow),\npre-tax-vs-post-tax base reduction, garnishment per-run + lifetime\ncaps, recurring percent + lifetime-cap clamping, the employer-side\nsplit, the net-negative warning, unknown-code warnings, and run-total\naggregation — the money core had zero coverage before this.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-payroll-employer-contribution-net-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"cd7e3021-8bb4-41a6-9b04-990dee6d9b32","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"rail-badge-clipping","type":"fixed","scope":"web","summary":"Unread count badges on the left rail icons are no longer clipped on their edge.","body":"The Chat and Support unread-count badges on the left navigation rail were\nbeing cut off. The rail's scrolling module group is centred, so it collapsed\nto the 40px icon width, and `overflow-y: auto` (which forces `overflow-x` to\nclip) then sliced off the badge where it overhangs the icon's edge. The group\nnow stretches to the full rail width, so the badge has room while the icons\nstay centred.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-rail-badge-clipping.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"9ec65972-1d75-4f10-93dc-be5880847478","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-quality-fixes","type":"fixed","scope":"sales","summary":"Stop duplicate invoice emails, surface the operator pay-link + hidden sub-nav pages, sync the action manifest.","body":"# Sales — quality fixes from the clients/sales gap re-verification\n\nA re-verification of the 2026-05-29 gap analysis (most of which was already remediated) surfaced a handful of genuinely-open, low-effort items. Fixed:\n\n## Fixed\n\n- **Duplicate invoice emails on double-click / retry.** Three send paths in [invoice-email.ts](modules/sales/src/actions/invoice-email.ts) keyed their email idempotency on a per-call timestamp, defeating the email module's 24h dedup window — a double-click or webhook retry sent two emails. The one-time **send** now uses a fully-stable `invoice + recipient` key (matching the quotation/receipt convention); the **reminders** (which recur over an invoice's overdue life) use a day-bucketed key, so distinct reminders days apart still send while same-day double-fires collapse.\n- **Operator \"Send payment link\" button.** The `sales.invoice.send_payment_link` action was API-only; the invoice detail page now has a **Send payment link** button (visible while the invoice is collectible) that mints a hosted-checkout link and copies it to the clipboard. Shows a clear \"no payment provider configured\" message instead of a generic error. Completes the operator side of the pay-by-link loop (the public `/i/<id>` Pay button was wired earlier).\n- **Hidden Sales pages now discoverable.** Added sub-nav entries for **Credit notes**, **Recurring**, **Approvals**, and **Settings** — these routes existed but were reachable only via deep links / count-hidden KPIs.\n\n## Chore\n\n- Synced the `salesActions` manifest array with the action registry — `dashboardSummary`, `revenueByMonth`, `seedDefaultTaxRates`, and `payPublicInvoice` were registered but missing from the array that feeds OpenAPI/MCP iterators.\n\nRe-verification + remaining backlog: [docs/plans/CLIENTS_SALES_GAP_ANALYSIS.md](docs/plans/CLIENTS_SALES_GAP_ANALYSIS.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-quality-fixes.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f195abfe-eb64-49c6-aac0-e24e6454f764","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-email-idempotency-keys","type":"fixed","scope":"sales","summary":"Sales email sends no longer embed a millisecond timestamp in their idempotency key, so double-clicks and retries dedup within the 24h window instead of sending twice.","body":"The email module dedups outbound sends by `idempotencyKey` within a\n24-hour window (a duplicate key returns the existing row with\n`isNew: false`, no second send). Several sales send paths appended\n`.${Date.now()}` to their key, which made every key unique to the\nmillisecond — completely defeating the dedup. A double-clicked \"Send\"\nbutton or a job retry sent the customer two identical emails.\n\nRemoved the timestamp from the one-shot send keys so they dedup on\ntheir natural identity (entity + recipient):\n\n- `sales.credit_note.send.<id>.<to>`\n- `sales.payment.receipt.<paymentId>.<to>`\n- `sales.quotation.<quotationId>.<to>`\n- `sales.statement.<companyId>.<from>.<to>` (the date range already\n  discriminates distinct statements)\n\nA re-send of the same document to the same recipient within 24h now\ncollapses to a single send; manual re-sends after the window still go\nthrough.\n\nNote: the three keys in `invoice-email.ts` (one send + two dunning\nreminders) are intentionally left for a follow-up — that file is under\nconcurrent edit, and the reminder keys additionally need a\nday-bucketed discriminator (so a dunning reminder can re-send on a\nlater day while a same-day retry dedups) rather than a flat removal.\n\nNo schema, action signature, or API change. sales tests 60/60.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-email-idempotency-keys.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"2e27b9dc-2ea4-4fa4-934c-0e04c565c38d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-external-send-dangerous","type":"fixed","scope":"sales","summary":"External email-sending sales actions now require AI confirmation (dangerous flag).","body":"Four sales actions that send email to the customer — `sales.credit_note.send_email`, `sales.payment.send_receipt`, `sales.quotation.send_email`, and `sales.client.statement_of_account.send_email` — were flagged `dangerous: false`, contradicting the action contract (\"sends anything externally → `dangerous: true`\"). They now match `sales.invoice.send_email` and require the AI runtime to confirm before firing, so an agent can no longer email a customer without a confirmation gate. No change to the operator UI path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-external-send-dangerous.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"aacea720-7478-40f7-a922-3c36fa49899d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-per-kind-meta-seo-override","type":"changed","scope":"website","summary":"Phase 6 of the gap-closure plan — per-kind meta validation + per-section seoOverride field for hero / cta_footer / feature_grid.","body":"`website_pages.meta` used to be a permissive JSONB bag — typos like\n`authour` would silently pass through and disappear into the\nread-side passthrough. Phase 6 of `WEBSITE_GAP_CLOSURE_PLAN.md`\ntightens write-time validation per-kind while keeping the read\nside fully backwards-compatible so existing rows never break.\n\n- **Per-kind meta schemas** (8 strict Zod shapes, one per page\n  kind) live in `modules/website/src/schemas/sections.ts` under\n  the `PageMetaSchemas` export. Each is `.strict()` — unknown\n  fields are rejected at write time:\n\n  - `page` → `{}` (no kind-specific fields)\n  - `module` → `{ moduleSlug?, accentColor? (#hex), moduleStatus?\n    (shipped|beta|coming-soon), gaSlug? }`\n  - `persona` → `{ persona?, keyword? }`\n  - `compare` → `{ competitor (REQUIRED), competitorLogo? }`\n  - `integration` → `{ partnerName (REQUIRED), partnerLogo?,\n    partnerSlug? (kebab-case) }`\n  - `blog` → `{ author?, authorAvatar?, readingTimeMin? }`\n  - `changelog` → `{ version (REQUIRED), releaseDate? }`\n  - `legal` → `{ effectiveDate? }`\n\n  `metaSchemaForKind(kind)` helper picks the right one in the\n  action handlers.\n\n- **`createPage` + `updatePage` validate meta against the per-kind\n  schema** before writing. Failed validation returns\n  `validation_failed` with a human-readable issue list (e.g.\n  `\"Invalid meta for kind 'blog': authour Unrecognized key.\"`).\n  Update uses the existing row's kind — callers don't supply it.\n\n- **`PageMetaSchema` (read-side) stays permissive** — it's a\n  union of every per-kind field plus `.passthrough()`. Existing\n  rows whose meta predates the tightening continue to load\n  cleanly; the change is forward-only and needs no SQL backfill.\n\n- **Per-section SEO override** — `hero`, `cta_footer`, and\n  `feature_grid` sections gained an optional\n  `seoOverride?: { title?, ogImage? }` (strict; ogImage must be a\n  URL). The marketing renderer can then pick the first matching\n  section's override to override the page-level `<title>` /\n  `og:image` — useful when one block carries the canonical visual\n  identity for a launch page.\n\n- **Admin UI** — new-page form's \"Meta (JSON)\" hint is now\n  per-kind: showing the placeholder + a concise field list for\n  the currently-selected kind. The hint mirrors the strict\n  schema, so what the operator sees is exactly what the server\n  will accept.\n\n- **9 new tests** — 7 for per-kind validation (happy paths for\n  blog + compare; rejected typos / missing required / invalid\n  hex / unknown-on-page / update-via-row-kind) + 2 for the\n  seoOverride (happy path + non-URL rejection). 118/118 module\n  tests pass.\n\nPhase 7 (multi-user edit-conflict guard with\n`expectedUpdatedAt`, ~0.5 day) is queued next per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-per-kind-meta-seo-override.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a399049d-a0b3-4efe-b54d-7d9c79d7967a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-per-page-allowed-blocks","type":"added","scope":"website","summary":"Phase 11 of the gap-closure plan — per-page `meta.allowedBlockTypes` whitelist with write-time enforcement.","body":"Phase 7 of the original CMS already had an org-wide\n`features.allowedBlockTypes`, but operators wanted finer control\n— \"this blog template should only use prose + hero + cta_footer,\neven though the rest of the site is unrestricted.\" Phase 11 of\n`WEBSITE_GAP_CLOSURE_PLAN.md` adds per-page enforcement.\n\n- **Schema** — every per-kind meta schema now mixes in a shared\n  `PageMetaBase` carrying optional `allowedBlockTypes: SectionTypeSchema[]`\n  (max 20). New `SectionTypeSchema` literal union in\n  `modules/website/src/schemas/sections.ts` enumerates the 14\n  block types; reused by the action handler for validation.\n\n- **Handler enforcement** — `createPage` + `updatePage` call a new\n  `findDisallowedSectionTypes(sections, allowed)` helper after\n  per-kind meta validation. Any section whose `type` isn't in the\n  whitelist surfaces as `validation_failed` with the offending\n  type + the allowed list in the message. The error fires on\n  every write that touches sections OR meta, so tightening the\n  whitelist without re-writing existing sections (which would\n  leave the page in an invalid state) is also rejected at update\n  time.\n\n- **No restriction when undefined** — `allowedBlockTypes` is\n  optional. When omitted the org-wide `features.allowedBlockTypes`\n  takes over (existing behaviour). Empty array `[]` is treated as\n  \"no per-page restriction\" (same as undefined) — operators who\n  want a true lock-down list the types currently present.\n\n- **Admin UI** — new-page form's per-kind META_HINT (from Phase\n  6) gained an `allowedBlockTypes` mention per kind, plus a\n  catalogue line listing every valid block-type string below the\n  meta textarea so operators don't have to guess the spellings.\n  No structured multi-select yet — the meta editor is still JSON;\n  a structured editor lands when the broader meta UI is rebuilt.\n\n- **6 new tests** — happy create with matching whitelist,\n  rejecting a disallowed block on create, no restriction when\n  omitted, update rejecting a new section that conflicts with the\n  existing whitelist, update rejecting meta-tightening that\n  invalidates existing sections, Zod-enum rejecting unknown\n  block-type strings. 148/148 module tests pass.\n\nPhase 12 (DB content search — `sections_text` generated column +\nGIN index + `website.page.search` action, ~2 days) is queued next\nper the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-per-page-allowed-blocks.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"a9857633-2e5d-44a7-a63b-add7385fc1a8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-month-end-billing-dates","type":"fixed","scope":"sales","summary":"Monthly subscriptions and recurring invoices anchored to the 29th–31st no longer skip a month or drift later each cycle.","body":"# Month-end billing dates no longer drift\n\nA subscription or recurring-invoice schedule anchored to the 29th, 30th, or 31st used to bill on the wrong date. The next-run date was computed with a naive \"add one month\", so e.g. a Jan-31 schedule rolled to \"Feb-31\" — which silently became **early March**, skipping February entirely and pushing every subsequent cycle later.\n\nMonth-based cadences (monthly / quarterly / annually for subscriptions; month intervals for recurring templates) now anchor to a fixed day-of-month — the subscription's `billingDay` when set, otherwise the original start day — clamped to each target month's length. So:\n\n- Jan-31 → Feb-28 (or Feb-29 in a leap year) → Mar-31 → Apr-30 …\n- the anchor snaps back to the 31st whenever the month is long enough — no permanent drift, no skipped month\n- the time-of-day and leap years are handled correctly\n\nThe arithmetic lives in a single shared, fully-tested helper (`modules/sales/src/lib/billing-dates.ts`) used by both the subscription engine and the recurring-template engine.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-month-end-billing-dates.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f0c7db45-0538-49c5-b0b5-902c969cefc8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-payment-detail-sheet","type":"added","scope":"sales","summary":"/sales/payments — per-payment detail + receipt sheet (resend receipt, refund history, invoice link).","body":"Added a \"Details\" affordance to each row on the payments ledger that opens a payment detail sheet:\n\n- **Headline** — amount + payer + a status badge (received / N refundable / fully refunded).\n- **Facts** — method, received date, reference, and a link straight to the parent invoice (whose PDF is the printable receipt, stamped PAID).\n- **Refund history** — every refund against the payment (amount, reason, status, date) via `sales.payment.list_refunds`, with a skeleton while loading and an empty state.\n- **Actions** — \"Resend receipt\" (re-fires `sales.payment.send_receipt` — the same composer that now sends automatically on record) and \"Refund\" (hands off to the existing refund modal, capped at the net-of-prior-refunds remaining).\n\nNo new action needed — the sheet composes from the loaded ledger row + the existing refund/receipt actions.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-payment-detail-sheet.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"29062ffa-716c-40d4-85c6-809796a6e2a0","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-payment-receipt-dedupe","type":"fixed","scope":"sales","summary":"Recording a payment sends exactly ONE receipt email (was three), and it's the PDF receipt.","body":"Recording a single payment fired **three** customer emails because three independent paths each sent one:\n\n1. `sales.payment.record` sent the receipt inline (direct `sendPaymentReceiptEmail` call).\n2. The `sales.payment.recorded` subscriber sent a plain \"payment recorded\" notice.\n3. The `sales.invoice.paid` subscriber sent an \"invoice paid\" email (fires on every fully-settling payment).\n\nThree different idempotency keys → three emails. Consolidated to **one**, per the email-integration rule (sends belong in a subscriber, not the action):\n\n- **`sales.payment.record`** no longer sends inline — it emits `sales.payment.recorded` carrying the caller's `sendReceipt` / `receiptTo` intent.\n- **The `sales.payment.recorded` subscriber** is now the single owner of the customer email and sends the **actual receipt** (receipt body + invoice PDF that stamps PAID once settled) via `sales.payment.send_receipt` — the same composer the operator's manual resend uses.\n- **The `sales.invoice.paid` subscriber** no longer emails the customer (the receipt already covers it); the event is retained for non-email consumers + a future internal owner alert.\n\nNet: one receipt email per payment (partial or full), honouring an opt-out (`sendReceipt: false`, e.g. the payment-gateway path).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-payment-receipt-dedupe.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f65a3207-b681-4861-ae30-768f29bfa2d8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-payment-receipt-followups","type":"fixed","scope":"sales","summary":"Payment-receipt follow-ups — restore primary-contact fallback, log failures, real resend, honest docs.","body":"Follow-ups from an adversarial review of the payment-receipt consolidation (which verified the core fix is correct — exactly one receipt per payment on every path, retry-safe):\n\n- **Restored the primary-contact recipient fallback** (regression fix). Consolidating the send onto `sales.payment.send_receipt` had narrowed recipient resolution to `to → company billingEmail`, dropping the primary-contact fallback the previous subscriber had. An org that sets a primary contact instead of a billing email would have silently received no receipt. The receipt action's cascade is now `to → billingEmail → primary-contact email` (parity with the invoice senders).\n- **Failed receipts are no longer silent.** The `payment.recorded` subscriber now captures the receipt action's `Result` and logs a warning on failure (the action returns `err(...)` rather than throwing, so the prior `try/catch` saw nothing).\n- **\"Resend receipt\" actually resends.** The idempotency key has no timestamp (correct — it dedupes at-least-once retries), so an operator resend within 24h silently no-op'd while toasting success. Added a `forceResend` flag (set only by the manual UI button, never the subscriber) that appends a nonce so a deliberate resend always delivers.\n- **Readiness gate fixed.** The receipt path is now gated on `email.outbound.send` (the action it actually uses) instead of the unrelated `notifications.dispatch.fan_out`.\n- **Honest docs.** Corrected the `sales.payment.send_receipt` description (idempotency is per-payment-per-recipient, not \"via timestamp\") and added the receipt-email side-effect + `sendReceipt`/`receiptTo` opt-out to `sales.payment.record`'s description (per the actions rule that external side-effects must be documented).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-payment-receipt-followups.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"f96f6402-cc4b-43ce-ba7f-211af7b0dc8d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-payment-reconciliation-fix","type":"fixed","scope":"sales","summary":"Pay-by-link payments now reconcile — fixed the policy-denied gateway subscriber and made payment recording idempotent on reference so webhook retries don't double-record.","body":"Two confirmed defects in the online-payment reconciliation loop,\nsurfaced by a module audit and fixed together because they're the same\nflow.\n\n**Gateway payments were silently denied.** The\n`payments.charge.succeeded` → sales subscriber created its system\ncontext with `permissions: ['sales:payment:create', 'sales:invoice:read',\n'sales:invoice:update']`. But `sales.payment.record` is gated by\n`paymentWritePolicy`, which requires `sales:payment:record` (or\n`sales:admin`) — and neither `sales:payment:create` nor\n`sales:invoice:update` exists in the permission catalog at all. So\n**every pay-by-link / gateway-captured payment hit `policy_denied`**\nand the invoice was never marked paid, even though the charge\nsucceeded. The subscriber now grants the real `sales:payment:record`\npermission the action checks.\n\n**Payment recording is now idempotent on `reference`.** A gateway\nwebhook can fire `charge.succeeded` more than once (provider retry),\nand `sales.payment.record` did an unconditional INSERT — so a retry\ndouble-recorded the payment and double-counted it toward the invoice\nbalance. The handler now, when a non-empty `reference` is supplied,\nfirst looks up a live (non-deleted) payment for the same invoice with\nthat exact reference and returns it unchanged instead of inserting a\nduplicate. Manual payments without a reference are unaffected (nothing\nto dedup on). The gateway subscriber always passes the provider charge\nid as the reference, so its retries are now safe.\n\nAdds `modules/sales/src/actions/payment.test.ts` covering the policy\ngate, invoice-state guards, and both idempotency branches (duplicate\nreference → returns existing id with no insert; fresh reference →\ninserts + emits `sales.payment.recorded` + `sales.invoice.paid`).\nsales tests 55 → 60.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-payment-reconciliation-fix.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"896eb71c-7d86-4b50-b0be-34ae28f29243","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-recurring-atomic-billing","type":"fixed","scope":"sales","summary":"Recurring-invoice generation is now atomic + concurrency-safe — no more double-billing on retry or multi-worker sweeps.","body":"# Sales — recurring invoice generation can no longer double-bill\n\n`sales.recurring.generate_now` created the invoice and advanced the template's `nextRunDate` in **two separate, un-transacted writes**. If the advance failed after the invoice was created (network blip, crash), the next hourly cron sweep re-selected the template (its `nextRunDate` was never moved) and generated a **duplicate invoice for the same cycle**. Two worker replicas sweeping concurrently could likewise both bill. (The same class of bug was just fixed for customer subscriptions; this closes it for operator recurring templates too.)\n\n## Fixed\n\n- **Atomic generation** ([recurring.ts](modules/sales/src/actions/recurring.ts)) — invoice creation, the `invoices.recurringTemplateId` back-pointer, the `nextRunDate`/occurrence/`completed` advance, and the `recurring.invoice.generated` event now all run in **one transaction**, gated by an **optimistic lock** on the `nextRunDate` that was read (`WHERE … AND next_run_date = <read value>` + `RETURNING`). If anything fails the whole thing rolls back (the cycle retries cleanly next sweep — no missed bill); if a second replica races, its conditional UPDATE matches zero rows and its invoice insert rolls back — no double-bill.\n- **Cron treats the optimistic-lock loser as a no-op** ([sales-revamp-cron.ts](modules/sales/src/jobs/sales-revamp-cron.ts)) — a `conflict` from a concurrent sweep is no longer counted as a generation failure; failure logs now include `orgId`.\n\n## Note\n\n`nextRunFrom`/`advanceDate` month-end arithmetic (a `billingDay`-aware schedule that doesn't drift on 29–31st starts) and org-timezone due dates for recurring templates remain follow-ups — this change is scoped to the double-billing correctness fix that the adversarial review flagged as critical.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-recurring-atomic-billing.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"25a342ee-309c-4135-82b7-de1ac5676231","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-static-branding-leak","type":"fixed","scope":"sales","summary":"Sales emails and action examples no longer leak the \"Helios\" codename on unbranded deployments.","body":"Closed four static-branding leaks in the sales module (per the white-label contract):\n\n- **Credit-note + statement-of-account emails** read `platform_settings.app_name` and fell back to the literal `'Helios'` when a deployment hadn't set a brand — surfacing the codename in customer-facing email subjects/bodies. They now sanitize through the same `resolveBrandAppName` guard the invoice email uses (sentinel / npm-scope / empty → \"your workspace\").\n- **`sales.invoice.public.get` and `sales.quotation.public.get` examples** hard-coded `appName: 'Helios'` in their catalog-visible payloads; replaced with the neutral placeholder `'Platform'`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-static-branding-leak.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"aa7bb0ba-64ed-4f02-957e-7ce01f54143e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"sales-subscription-billing","type":"added","scope":"sales","summary":"Sales subscriptions now auto-generate + issue their recurring invoices on schedule.","body":"# Sales — subscription billing actually bills\n\nSales subscriptions (committed recurring contracts) stored a `nextRunAt` but nothing ever acted on it — `sales.subscription.cycled` had an email consumer but **no emitter**, so subscriptions never produced invoices. This closes the biggest functional hole in the module: recurring revenue now bills automatically.\n\n## What's new\n\n- **`sales.subscription.generate_invoice`** ([subscription.ts](modules/sales/src/actions/subscription.ts)) — generates one invoice for an active subscription's current cycle from its stored line items, stamps the invoice's `subscriptionId` back-pointer, advances `nextRunAt` by one interval, and terminates the subscription (`cancelled`, reason \"Reached subscription end date\") when the next cycle would fall past `endDate`. Emits `sales.subscription.cycled` (the existing customer-notification email subscriber consumes it).\n- **Billing sweep** — passes in the hourly `runSalesRevampCronOnce` ([sales-revamp-cron.ts](modules/sales/src/jobs/sales-revamp-cron.ts)): a **trial→active** pass flips trialing subscriptions whose trial has elapsed (nothing else did this — trials previously never converted or billed), then the **billing pass** finds every active subscription whose `nextRunAt` is due, generates the cycle invoice, and **auto-issues** it (subscriptions are committed contracts — unlike recurring *templates*, which respect an operator `autoIssue` flag).\n\n## Correctness (hardened after an adversarial review)\n\n- **No double-billing.** Invoice creation + the `nextRunAt` advance run in **one transaction**, gated by an **optimistic lock** on the `nextRunAt` we read. Two concurrent cron replicas can't both bill — the loser's conditional UPDATE matches zero rows and its invoice insert rolls back. If the invoice insert fails, the whole transaction rolls back and the cycle is retried next sweep (no missed bill either). The `sales.subscription.cycled` (+ `sales.subscription.cancelled` on natural end) events emit on the transaction's bus so they commit atomically with the writes.\n- **Org-timezone due date** (net-14 via `addDaysInTz`, not UTC arithmetic).\n- **Null/over-overdue guards:** an active subscription with a null `nextRunAt` is rejected (don't bill off the wall clock); a `nextRunAt` more than a year overdue is refused for manual review (no year-of-invoices drip).\n- **Natural end emits `subscriptionCancelled`** so the churn email fires (terminal status is `cancelled` + a distinguishing reason — the UI renders only `active|trialing|paused|cancelled`).\n- **Resuming** a paused/cancelled subscription resets `nextRunAt` to one interval from now, so a stale schedule doesn't bill immediately.\n- **Auto-issue failures** are counted + logged with the invoice id (the draft is recoverable; `nextRunAt` already advanced).\n\n## Notes\n\n- No migration: reuses `subscriptions.nextRunAt` / `lastRunAt` / `trialEndDate` + `invoices.subscriptionId`.\n- Guard tests: policy denial, missing/non-active/empty subscription, input validation, null-nextRunAt, over-a-year-overdue. The full atomic-billing money math is integration-tested with the broader money-path suite (deferred).\n\nPart of the clients/sales gap remediation: [docs/plans/CLIENTS_SALES_GAP_ANALYSIS.md](docs/plans/CLIENTS_SALES_GAP_ANALYSIS.md).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-sales-subscription-billing.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"b284df64-ad81-4d9b-9862-8f654f4e072a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"status-color-tokens","type":"fixed","scope":"web","summary":"Red/amber/green status colours now render across the app instead of falling back to grey.","body":"A family of semantic colour tokens was referenced in hundreds of places\nacross the app but never actually defined in the design-system stylesheet:\n\n- the bare `--danger` / `--warning` / `--success` / `--info`, and\n- the `--accent-<tone>` \"soft status\" set (`--accent-danger`,\n  `--accent-danger-bg`, `--accent-danger-fg`, `--accent-danger-border`, and\n  the warning / success / info equivalents).\n\nBecause the variables didn't exist, `var(--danger)` was a guaranteed-invalid\nvalue, so anything using them — required-field asterisks, delete/destructive\nlinks, success ticks and \"live\" dots, error text, soft status chips, the\nmaintenance + announcement banners — silently rendered grey (or a hard-coded\nhex) instead of its intended colour.\n\nThese tokens are now defined once over the existing `--color-<tone>-500`\nscale, using transparent `color-mix()` for the tint and border so they adapt\nto light and dark mode automatically. Every existing usage now renders in the\ncorrect tone with no other changes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-status-color-tokens.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"8f1a1c2c-65f6-4f14-94ab-e5b5e54b6c3e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-approval-workflow","type":"added","scope":"website","summary":"Phase 4 of the gap-closure plan — request-review / approve-or-reject workflow with a new pending_review status + :approve permission.","body":"Before today, any actor with `platform:website:page:publish` could\nship a page immediately — no second pair of eyes required. Phase 4\nof `WEBSITE_GAP_CLOSURE_PLAN.md` adds an explicit review gate\noperators can opt into without breaking the existing fast path.\n\n- **Migration `0201_0202_website_pending_review.sql`** — adds the\n  `'pending_review'` value to the existing `website_page_status`\n  enum. New lifecycle:\n\n  ```\n  draft → pending_review              (website.page.request_review)\n  pending_review → draft              (website.page.reject_review)\n  pending_review → published          (website.page.publish; +:approve)\n  draft → published                   (website.page.publish; unchanged)\n  ```\n\n- **2 new admin actions**:\n  - `website.page.request_review(id, note?)` — author moves their\n    own draft into review. Optional note lands in the revision log\n    so the approver sees context. Gated by `:update`. Conflict on\n    any status other than `draft`.\n  - `website.page.reject_review(id, reason)` — approver kicks a\n    `pending_review` row back to `draft` with required feedback.\n    Reason snapshotted into the revision log. Gated by the new\n    `:approve` permission. Conflict on any status other than\n    `pending_review`.\n\n- **Publish gate hardened** — the existing `website.page.publish`\n  action now additionally requires `platform:website:page:approve`\n  when the row is in `pending_review`. Actors with only `:publish`\n  see `policy_denied` until an approver helps out. Direct\n  draft-to-published publishing is unchanged (still `:publish` alone).\n\n- **Scheduled-publish hardened** — `website.page.schedule_publish`\n  is now restricted to `status === 'draft'`. Scheduling a\n  `pending_review` row used to bypass the approve gate (the cron's\n  system context only carries `:publish`); the action now returns\n  `conflict` for any non-draft. Approvers publish immediately\n  rather than schedule.\n\n- **New permission key** — `platform:website:page:approve` added to\n  `packages/auth/src/roles.ts` with a one-line description.\n  Root-only by default; NOT in `STANDARD_ROLE_BLUEPRINTS`.\n\n- **Admin UI**:\n  - Page list: status filter gains \"Pending review\"; status badges\n    render via a new `STATUS_LABEL` map so `pending_review` shows\n    as \"pending review\" (human) rather than `pending_review` (raw).\n  - Page editor: warning-tone banner above the editor when status\n    is `pending_review` (different copy for approver vs author).\n    Header action buttons re-shaped: approvers see \"Approve &\n    publish\" + \"Reject\" on `pending_review`; authors see \"Publish\n    now\" + \"Request review\" on `draft`. The publish confirm dialog\n    re-labels to \"Approve & publish\" when called on a\n    `pending_review` row.\n  - New \"Request review\" dialog with an optional note textarea +\n    \"Reject\" dialog with a required reason textarea.\n\n- **12 new tests** — 5 for `request_review` + 4 for `reject_review`\n  + 2 for the publish-gate-on-pending-review + 1 for the\n  schedule-publish lockout. 99/99 module tests pass.\n\nBehaviour contract: this is *opt-in*. Operators who want a single\nfast-path keep their existing flow (just don't use Request review;\npublish goes directly from draft to published). Operators who want\na review gate grant `:publish` widely + `:approve` narrowly, then\ntrain authors to always submit for review.\n\nPhase 5 (bulk operations — batch publish/archive/status-set,\n~1 day) is queued next per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-approval-workflow.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"82fc60a6-5c72-4236-82a2-e870abee1bb8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-archived-recovery","type":"added","scope":"website","summary":"Phase 8 of the gap-closure plan — soft-delete recovery UI at /saas/website/archived + website.page.restore action.","body":"Until today, archiving a CMS page was a one-way trip from the admin\nUI — the soft-delete was implemented but there was no Restore\nbutton. Phase 8 of `WEBSITE_GAP_CLOSURE_PLAN.md` closes that loop.\n\n- **2 new actions**:\n  - `website.page.restore(id)` — clears `deleted_at` and flips\n    status back to `draft`. Snapshots the archived state into the\n    revision log first so the restore is itself reversible\n    (re-archive doesn't lose the historic versions). Returns\n    `conflict` if the row was never archived. Gated by the same\n    `:archive` perm as archive (symmetric authority — whoever can\n    archive can recover).\n  - `website.page.list_archived(kind?, limit)` — returns\n    soft-deleted rows for the calling org, sorted by `deleted_at\n    desc`. Filterable by kind. Gated by `:read`.\n\n- **Admin UI** — new `/saas/website/archived` route lists archived\n  pages with kind filter, locale column, archive timestamp, and a\n  per-row Restore button (with confirm dialog explaining the\n  restore is reversible). New \"Archived\" button on the\n  `/saas/website` index nav row.\n\n- **6 new tests** — 4 for `restore` (happy + conflict-when-not-\n  archived + not_found + policy denial) + 2 for `list_archived`\n  (happy + policy denial). 128/128 module tests pass.\n\nNote: `apps/web/src/routeTree.gen.ts` is auto-generated by\nTanStack Router on dev/build. It hasn't been regenerated since the\nnew `website.archived.tsx` file was added — the next dev/build run\nwill pick it up automatically. Same pattern as Phase 1 (redirects);\nleft for the user's parallel batch.\n\nPhase 9 (slug-collision guard — DB trigger + audit table + docs,\n~0.5 day) is queued next per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-archived-recovery.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"97135008-3bc5-4fc8-a51a-0e77eb637b1c","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-bulk-operations","type":"added","scope":"website","summary":"Phase 5 of the gap-closure plan — batch publish / archive / set-status actions + admin checkbox UI and sticky action bar.","body":"Migrating content sets used to mean editing one page at a time\nthrough the admin. Phase 5 of `WEBSITE_GAP_CLOSURE_PLAN.md` adds\n3 batch actions + a row-selection UI so operators can publish /\narchive / draft many pages in one click.\n\n- **3 new actions**:\n  - `website.page.batch_publish(pageIds[])` — same per-row\n    semantics as the single-row publish action (`status=published`,\n    `published_at=now()`, `publish_at` cleared, `website.page.published`\n    fires per row). Pending-review rows are skipped unless the\n    actor also holds `:approve`. Archived rows are skipped. Capped\n    at 200 ids/call.\n  - `website.page.batch_archive(pageIds[])` — same semantics as\n    archive. Already-archived rows are reported as success\n    (idempotent). Capped at 200 ids/call.\n  - `website.page.batch_set_status(pageIds[], status)` — accepts\n    `'draft'` or `'archived'`. `'draft'` clears `deleted_at` (so\n    un-archive works) AND clears `publish_at` (operators expect a\n    status change to cancel a scheduled publish). `'published'` is\n    rejected — use `batch_publish` so `published_at` + event fire\n    correctly. `'pending_review'` is rejected because it needs the\n    per-page `note` context that doesn't fit the bulk pattern.\n\n- **Per-page result envelope** — every batch action returns\n  `{ total, succeeded, failed, results[] }` where each result entry\n  carries `{ pageId, ok, error? }`. Lets the UI render\n  \"Published 12 of 14; 2 skipped (pending review, archived).\"\n  rather than collapsing the outcome to a single ok/fail.\n\n- **Admin UI**:\n  - `/saas/website` page list: new checkbox column with a\n    select-all header (indeterminate when partial). Selected rows\n    get a subtle accent background.\n  - Sticky bulk-action bar slides in at the bottom of the page\n    when ≥1 row is selected: shows the count + Publish + Move to\n    draft + Archive (each gated by the actor's perms) + Clear.\n  - Three confirm dialogs (one per action) with a summary toast on\n    completion — green when all succeeded, warning when any\n    skipped.\n\n- **10 new tests** — 6 for `batch_publish` (happy + missing rows\n  reported as not_found + pending_review skipped + archived skipped\n  + empty list validation + policy denial) + 2 for `batch_archive`\n  (happy + idempotent already-archived) + 2 for `batch_set_status`\n  (un-archive case + reject invalid status enum). 109/109 module\n  tests pass.\n\nPhase 6 (per-section SEO + per-kind meta tightening) is queued\nnext per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-bulk-operations.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"d65975e4-edb0-4615-9b2e-091b61f65b60","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-content-search","type":"added","scope":"website","summary":"Phase 12 of the gap-closure plan — DB-side full-text search across CMS pages with ranked + snippeted results.","body":"Until today admin search was Pagefind post-render only — operators\ncouldn't ask \"show me every page that contains the phrase 'AI\nnative'.\" Phase 12 of `WEBSITE_GAP_CLOSURE_PLAN.md` adds Postgres\ntsvector full-text search at the DB level with sub-second response\ntimes even on large content sets.\n\n- **Migration `0205_0206_website_pages_search.sql`** — adds a\n  `sections_text` generated column (`GENERATED ALWAYS AS\n  (sections::text) STORED`) plus a GIN tsvector index covering\n  `title || description || sections_text`. The cast-to-text approach\n  is intentionally simple: tsvector tokenises JSON syntax as noise\n  and indexes the string leaves we care about. False positives on\n  JSON keys like `type` or `heading` are tolerable for admin\n  search where the snippet disambiguates.\n\n- **Drizzle schema** gains `sectionsText: text('sections_text')\n  .generatedAlwaysAs(sql\\`sections::text\\`)`. Never written\n  directly — the DB recomputes on every sections write.\n\n- **New action `website.page.search`** — `q, kind?, language?,\n  includeArchived?, limit?`. Uses `websearch_to_tsquery` so the\n  query supports quoted phrases (`\"action layer\"`), boolean\n  operators (`hero OR carousel`), and negation (`-marketing`).\n  Returns ranked matches with `ts_headline` snippets (≤2 fragments\n  / ≤20 words each, matched terms wrapped in `<b>…</b>`).\n  Soft-deleted rows always excluded; archived excluded unless\n  `includeArchived: true`. Gated by `:read`.\n\n- **Admin UI** — debounced (300ms) search bar above the filter\n  card on `/saas/website`. When active, the search-results panel\n  replaces the regular table. Each result row shows\n  title + kind badge + status badge + language + slug + rank\n  score + the headline snippet (rendered as trusted HTML — admin-\n  only surface). Filters still narrow search (kind, language,\n  archived-status).\n\n- **6 new tests** — happy path with `{rows: […]}` envelope,\n  empty result, raw-array driver shape, empty query rejected\n  (validation), over-long query rejected (validation), `:read`\n  policy gate. 154/154 module tests pass. Website + web app\n  typecheck both clean for my files.\n\n- **Future enhancement** — pgvector embedding column for semantic\n  search (vs lexical) is a clean next step. Lexical covers\n  \"find this string\" perfectly; semantic would cover \"find pages\n  about X\" without the operator needing to know the exact phrase.\n  Reserved here but not in scope for Phase 12.\n\nPhase 13 (Keystatic side-quest for static MDX) is the final\nqueued phase per the plan doc — optional, parallelisable.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-content-search.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"c7c91e60-33d1-41db-b0fd-330bc72e05cd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-edit-conflict-guard","type":"added","scope":"website","summary":"Phase 7 of the gap-closure plan — optimistic-concurrency guard on website.page.update + inline conflict banner in the editor.","body":"Before today, two editors hitting the same CMS page would silently\ntrample each other — the second save wins; the first one's\nchanges disappear without warning. Phase 7 of\n`WEBSITE_GAP_CLOSURE_PLAN.md` adds an opt-in optimistic-concurrency\nguard so concurrent edits surface as a banner the operator can\nresolve, not a silent data loss.\n\n- **`website.page.update` accepts optional `expectedUpdatedAt`\n  (ISO 8601).** When supplied, the handler compares against the\n  row's current `updated_at` (millisecond precision). Mismatch\n  returns `conflict` with a message that contains the actual\n  `updated_at` so the UI can render \"X edited this 30s ago\"\n  without a second round-trip. Omit the field to keep the legacy\n  last-write-wins behaviour — the CLI + AI tools rely on it.\n\n- **Admin UI** — `/saas/website/$id` page editor:\n  - Snapshots `page.updatedAt` to local state when the page loads.\n  - Sends it back as `expectedUpdatedAt` on every save.\n  - On a `conflict` response, shows an inline danger-tone banner\n    above the form with two actions:\n    - **Refresh (lose my changes)** — invalidates the query and\n      reloads from the server.\n    - **Keep mine (overwrite)** — refetches just to grab the\n      latest `updated_at`, then re-runs the save with the new\n      precondition (last-write-wins, but explicit).\n\n- **No schema change** — pure handler-level logic.\n\n- **4 new tests** — happy path (matching precondition), stale\n  precondition returns conflict with the actual updated_at in the\n  message, omitting precondition falls back to last-write-wins,\n  malformed precondition rejected as `validation_failed`. 122/122\n  module tests pass.\n\nPhase 8 (soft-delete recovery UI — `/saas/website/archived` list +\n`website.page.restore` action, ~0.5 day) is queued next per the\nplan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-edit-conflict-guard.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"80fee3c2-cf1c-4767-9763-c4640e9f70a6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-i18n","type":"added","scope":"website","summary":"Phase 3 of the gap-closure plan — localisation (i18n) for CMS pages with BCP-47 language tags + 'en' fallback + translate action.","body":"Phase 3 of `WEBSITE_GAP_CLOSURE_PLAN.md` closes the biggest CMS\ngap: the marketing site had `/es/` routes wired in Astro, but no\nlocalised CMS rows behind them. Today operators can author the\nsame page in multiple languages and the public reader picks the\nright one — with a clean fallback to `'en'` so a partial\ntranslation never serves a 404.\n\n- **Migration `0200_0201_website_language.sql`** — adds a `language\n  text NOT NULL DEFAULT 'en'` column on `website_pages` and swaps\n  the uniqueness constraint from `(org_id, kind, slug)` to\n  `(org_id, kind, slug, language)`. Free-form varchar so operators\n  can use any BCP-47 tag (`en`, `es`, `pt-BR`, `zh-CN`, `ar`, …)\n  without a Helios release. Existing rows backfill to `'en'`\n  automatically via the column default.\n\n- **1 new admin action** `website.page.translate(sourceId,\n  targetLanguage)` — clones an existing page into a draft in the\n  target locale. Title, description, sections, meta, tags are\n  copied verbatim so the translator overwrites in place. Conflict\n  if the target locale row already exists; `validation_failed` if\n  source/target languages match.\n\n- **Updated read actions**:\n  - `website.page.get_public(kind, slug, language?, fallback?)`\n    — accepts an optional locale; falls back to the `'en'` row when\n    missing unless `fallback: 'none'`. Two-step query keeps the\n    common 'en'-only path on one round-trip.\n  - `website.page.list_public(kind, language?)` — filters by locale\n    when set; otherwise returns every row.\n  - `website.page.list` (admin) — same filter.\n  - `website.page.create` — accepts `language` (defaults `'en'`).\n\n- **Marketing renderer** (`apps/marketing/src/lib/cms-runtime.ts`)\n  — `FetchPagesOptions` gained an optional `language` field that\n  threads through `listPages()`, `getPage()`, and the cache key\n  (so a request for `/es/pricing` and `/pricing` never share a\n  cache slot). Pass `Astro.params.lang` from the route layer.\n\n- **Settings** — new `features.supportedLanguages?: string[]` (BCP-47\n  tag array) so operators declare which languages their site\n  supports. Populates the admin translate picker eventually + powers\n  per-page `<link rel=\"alternate\" hreflang>` headers.\n\n- **Admin UI**:\n  - `/saas/website` page list — new \"Lang\" column showing the\n    BCP-47 tag, plus a free-text language filter in the filter card.\n  - `/saas/website/new` — Language input alongside Kind + Slug\n    (defaults `'en'`; BCP-47 pattern enforced client-side).\n  - `/saas/website/$id` page editor — locale badge in the header\n    next to status; new \"Translate to…\" button opens a dialog\n    that creates the target-locale draft and navigates to it.\n\n- **7 new tests** — 6 for `translatePage` (happy + same-language\n  rejection + not_found + conflict + archived rejection + policy\n  denial) + 3 for `getPagePublic` locale fallback (locale hit,\n  locale miss with `'en'` fallback, locale miss with\n  `fallback='none'`). 87/87 module tests pass.\n\nBehaviour contract: `'en'` is the fallback locale by convention.\nA future iteration may make this per-tenant configurable via the\nnew `features.supportedLanguages[0]`. For now every Helios\ndeployment treats `'en'` as the safe baseline that's always\npresent.\n\nPhase 4 (approval workflow — `pending_review` status + approve\nperm) is queued next per the plan doc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-i18n.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"ed62ad69-3598-4f7c-a3db-57ed59bf2844","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"website-media-lifecycle","type":"added","scope":"website","summary":"Phase 10 of the gap-closure plan — daily media-scrub cron + admin Orphaned/Deleted views for the media library.","body":"Until today, every image an operator uploaded stayed in\n`website_media` forever — there was no automatic cleanup, so the\nadmin grid filled up with files no page actually used. Phase 10 of\n`WEBSITE_GAP_CLOSURE_PLAN.md` adds a daily sweep that soft-deletes\norphans + an admin UI to preview what the next sweep will catch.\n\n- **`runMediaScrubSweep({ db, options, logger })`** in\n  `modules/website/src/jobs/media-scrub.ts` — per-org sweep:\n  1. Load non-deleted media older than `graceDays` (default 30).\n  2. Load all non-deleted pages' `sections` jsonb and join as text.\n  3. For each candidate, check if either `id` OR `storageKey`\n     appears anywhere in that text. If neither → soft-delete\n     (`deleted_at = now()`). Per-row try/catch keeps a bad update\n     from poisoning the rest.\n  4. Per-org try/catch keeps a bad org from poisoning the sweep.\n  Returns `{ orgsScanned, candidatesEvaluated, softDeleted,\n  failures[] }`.\n\n- **`isOrphanCandidate(media, sectionsText, now, graceDays?)`**\n  predicate exported from the same file. Action layer uses it for\n  the admin's \"Orphaned\" filter so the UI shows exactly what the\n  cron would catch next tick — no drift.\n\n- **Worker cron** `apps/worker/src/website-media-scrub-cron.ts` —\n  daily tick (every 24h, 10-minute post-boot offset to avoid\n  startup pool races). Logs `{orgsScanned, candidatesEvaluated,\n  softDeleted, failures}` per tick.\n\n- **R2 blob deletion is deferred.** The `@helios/storage` client\n  doesn't expose a delete method yet; the sweep soft-deletes the\n  DB row only. Future enhancement: scan `deleted_at < now() - 90d`\n  and call the storage delete API once it exists.\n\n- **`website.media.list` gained `view: 'active' | 'orphaned' |\n  'deleted'`** (default `'active'`; backwards compatible —\n  existing callers see no behaviour change). `'orphaned'`\n  recomputes candidates using `isOrphanCandidate`; `'deleted'`\n  returns soft-deleted rows ordered by `deleted_at desc` for\n  audit.\n\n- **Admin UI** — new pill tab strip on `/saas/website/media`:\n  Active / Orphaned / Deleted. Empty-state copy is view-aware.\n\n- **10 new tests** — 5 sweep scenarios (no-op when no orgs;\n  soft-delete an orphan and skip a referenced row; storageKey\n  match counts as referenced; custom graceDays; per-org failure\n  isolation) + 5 predicate scenarios (soft-deleted skipped, fresh\n  skipped, referenced-by-id skipped, referenced-by-storageKey\n  skipped, true orphan returns true). 142/142 module tests pass.\n\nPhase 11 (per-page allowed block-types — `meta.allowedBlockTypes?`\n+ write-time enforcement, ~0.5 day) is queued next per the plan\ndoc. Depends on Phase 6's per-kind meta tightening, which is\nalready shipped.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-30-website-media-lifecycle.md","internalOnly":false,"createdAt":"2026-06-04T01:28:37.570Z","updatedAt":"2026-06-04T01:28:37.570Z"},{"id":"78cd3fc9-1c81-42e5-8f97-8e1cdc8f5e1b","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"onboarding-completeness-pass-1","type":"fixed","scope":"web","summary":"2FA status now reflects DB truth on /settings/security; signups auto-send verification email; profile page surfaces a Verified/Unverified pill with inline resend; signup wizard shows a \"check your inbox\" notice.","body":"Four user-onboarding completeness bugs landed together:\n\n**1. /settings/security said \"2FA not setup\" after enrolment.**\nThe page reads `me.user.twoFactorEnabled` — but `/api/me` was\nselecting `id, email, name, image, type, status, phone, locale,\ntimezone` only, so the field came back as `undefined` and\ncoerced to false even when the DB had `two_factor_enabled =\ntrue`. Sign-in worked because the TOTP challenge reads from the\nDB directly. Now `/api/me` selects both `twoFactorEnabled` AND\n`emailVerified` on every hit (cookie cache notwithstanding), so\nthe security page and the new profile pill both see live truth.\n\n**2. New signups didn't get a verification email automatically.**\nBetter-Auth's `emailVerification.sendOnSignUp` was `false`, so\nthe email only went out when the user explicitly hit \"resend\"\nfrom `/verify-email`. Flipped to `true` — every new account now\ngets the link in their inbox the moment they finish account\ncreation. The send still flows through the unified email module\nvia the existing `sendEmailVerification` hook, so suppression,\naudit, and rate-limiting are unchanged.\n\n**3. /settings/profile had no email-verification status.**\nAdded an `<EmailVerificationPill>` underneath the (read-only)\nemail field. Verified addresses get a green \"Verified\" chip\nwith a check; unverified addresses get an amber chip plus an\ninline \"Send verification email\" button with the same 30-second\nresend cooldown the `/verify-email` page uses. The pill is\nportable — `useMe()`-driven, drops anywhere the user is signed\nin.\n\n**4. Signup wizard never said \"check your inbox\".**\nThe wizard now mounts a slim, non-blocking `<VerifyInboxNotice>`\nstrip across the workspace / modules / invite steps when the\nfreshly-signed-in user's `emailVerified` is still false. Auto-\nhides the instant verification lands. The complete step + the\ndashboard's existing `<VerifyEmailBanner>` take over from there.\n\nFiles touched:\n- `apps/web/src/server/me.ts` — added the two fields to the\n  select + the fallback shape.\n- `packages/auth/src/server.ts` — `sendOnSignUp: true`.\n- `apps/web/src/components/email-verification-pill.tsx` (new).\n- `apps/web/src/routes/settings/profile.tsx` — slotted the pill\n  below the read-only email field.\n- `apps/web/src/routes/signup.tsx` — `<VerifyInboxNotice>`\n  mounted from `<WizardShell>`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-05T01:29:09.292Z","updatedAt":"2026-06-05T01:29:09.292Z"},{"id":"2055134a-5425-4d1b-82c7-e8016ecb2787","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-monthly-attendance-report","type":"added","scope":"hrm","summary":"Monthly attendance summary report — payroll-ready day-by-day verdicts with deduction math.","body":"A comprehensive monthly attendance report now lives on every employee\ndetail page under a new **Attendance** tab. The report computes, for any\ncalendar month, a verdict for every day (Present, Late, Half day,\nAbsent, Paid leave, Unpaid leave, Half-day leave, Holiday, Weekend,\nNot scheduled, Future) and rolls those verdicts up into payroll-ready\nfigures: base days, absence deduction tenths, unpaid-leave deduction\ntenths, configurable late-penalty tenths, and the **net payable days**\nthat payroll consumes.\n\nThe new action `hrm.attendance.monthly_summary` is the single source of\ntruth — it joins the published roster, time entries, approved leave\n(with paid/unpaid distinction from the leave policy), org holidays, and\nthe org's weekend-day configuration, then calls a pure kernel\n(`modules/hrm/src/lib/monthly-attendance.ts`) that's covered by an\n8-case test matrix (clean week, weekend/holiday/absent, paid + unpaid\nleave + half-day, half-day classification, late grace, late-penalty\nrule with per-day cap, future-day handling, holiday-over-weekend-over-\nleave precedence).\n\nThe UI tab renders a KPI strip, a 7-column calendar heatmap with status\nglyphs, a payroll-basis panel highlighting **payable days** as the\nfocus number, a scrollable per-day table with an \"Exceptions only\"\nfilter, an assumptions footer, and a status legend. Month picker with\nPrev / Next / This month / Last month, plus collapsible knobs for the\nlate-grace and per-org late-penalty rule. Print-ready.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:55.629Z","updatedAt":"2026-06-08T16:37:55.629Z"},{"id":"65989336-1263-4335-8f9d-3bb9a7bcb5b7","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-persisted-late-penalty","type":"added","scope":"hrm","summary":"Per-org late-penalty rule now persists in the clock policy and drives monthly attendance + payroll automatically.","body":"The monthly attendance report's late-penalty knobs used to be transient —\nconfigurable per-call from the UI but lost the moment the payroll cron\nor any non-UI consumer hit the same action. The rule now lives on\n`hrm_clock_policies` (three new integer columns: minutes-per-unit,\ndeduction-minutes-per-unit, max-deduction-minutes-per-day) so every\nconsumer reads from one source of truth.\n\nAdmins configure the policy in **Settings → HRM Rules → Late penalty\n(payroll deductions)**. The new section explains the mental model\noperator-side (\"every N minutes late ⇒ deduct M minutes of pay, capped\nat K minutes per day\") and the figures stay integer-only. Defaults\nkeep the penalty disabled (0 minutes per unit) so existing tenants see\nno behaviour change until they opt in.\n\nThe monthly attendance action (`hrm.attendance.monthly_summary`) now\nfalls back to this policy when the caller doesn't pass an override,\nconverting the minutes-shaped rule into the kernel's day-tenth shape\nusing the same row's `min_work_minutes_per_day` as the denominator. The\nreport UI's collapsible \"Deduction rules\" knobs sync from the resolved\npolicy on first load so users see the configured state immediately;\nthey can still tweak for what-if exploration, and the explicit value\nwins from then on. The `lateGraceMinutes` input is now truly optional —\nexplicit 0 means \"no grace\", omitted means \"use policy\".","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:53:56.759Z","updatedAt":"2026-06-08T16:53:56.759Z"},{"id":"34b97a2c-af8d-4924-af1d-951022a148bc","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-checkout-plan-fix-and-processing","type":"fixed","scope":"web","summary":"Paid-plan checkout in signup no longer errors with \"Sign in to call actions\"; workspace creation shows a 3-5s processing scene before the next step.","body":"Two signup-flow fixes:\n\n**\"Sign in to call actions\" during paid plan checkout.**\nBetween Account-step (account minted, session cookie set) and\nModules-step (org created), the user is in a no-membership\nstate. `createRequestContext` was correctly resolving the\nsession via `getSession()` but `requireAuth()` then bailed with\n`null` because there was no `memberships` row — which made\nevery action call (`payments.platform.configured`,\n`payments.signup.create_plan_checkout`, etc.) return\n`policy_denied` with \"Sign in to call actions.\" The user saw\nthe error and ended up bounced to the dashboard without ever\nhitting Stripe Checkout.\n\n`requireAuth()` now synthesises a no-membership principal in\nthis case: real `userId`, real session, but `orgId =\n00000000…` and an empty permission set. Actions whose policy\njust requires a signed-in user (signup checkout, onboarding\nself-reads) admit the caller; every permission-gated action\nstill policy-denies as it should. Mirrors the existing root-\nimpersonation short-circuit that synthesises a virtual\nprincipal for cross-org browsing.\n\n**Workspace-creation processing scene.**\nAfter the Modules step's `organization.create` resolves (plus\nthe prefs upsert, setup-state stamp, and optional demo seed),\nthe wizard used to flash to the invite step instantly — no\nvisual confirmation that the workspace was actually being\nprovisioned. New `<WorkspaceProcessingScene>` mirrors the\npayment-return loader: pulsing accent orb anchor, 4-step\nsequential ticker (\"Creating your workspace\" → \"Enabling your\nmodules\" → \"Setting up your access\" → \"Preparing your\ndashboard\"), filling progress bar capped at the final step.\nHeld for ~4.2s after the org-create chain succeeds, then\nnavigates. Honours `prefers-reduced-motion`.\n\nThe component is portable — reusable for the paid-plan return\npath once that lands in a follow-up (the same scene fronts\nthe brief gap between `/pay/return/<id>` confirming and the\ndashboard taking over).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:53:57.328Z","updatedAt":"2026-06-08T16:53:57.328Z"},{"id":"124ca6c9-329d-44a6-823b-282f432a81a2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-org-overview","type":"added","scope":"hrm","summary":"Org-wide monthly attendance overview — one row per employee with payable days, mounted on the HRM time page.","body":"The single-employee attendance report needed an org-wide entry point so\nHR can see every employee's payable days for the month in one place.\nAdded an \"Attendance\" view to HRM → Time tracking that lists every\nactive employee with present/late/absent counts, leave breakdown,\novertime hours, deductions, and the headline **Payable days** column.\nEach row has Open (drills into the employee's day grid) and PDF (the\nexisting per-employee PDF render) actions; a CSV export captures the\nwhole filtered set in payroll-shaped columns.\n\nThe view is powered by a new batched action\n`hrm.attendance.org_overview` that runs the verdict kernel per\nemployee but issues only six DB round-trips regardless of headcount —\nroster slots, time entries, leave, holidays, settings, and clock\npolicy are each fetched once across the whole filtered employee set\nand bucketed in memory. Payroll's cron can call the same action to\nget every employee's payable-day figure for the month in a single\nrequest.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:10:08.275Z","updatedAt":"2026-06-13T08:10:08.275Z"},{"id":"e891c166-33ea-4263-98cf-a5ec2531b3cf","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-pdf","type":"changed","scope":"hrm","summary":"Monthly attendance report now downloads a branded PDF instead of a blank browser print.","body":"The \"Print\" button on the monthly attendance report used to call\n`window.print()` — which produced a blank page because the surrounding\napp shell's print stylesheet hides the body content. The button now\ndownloads a real server-rendered PDF via the new\n`hrm.attendance.render_pdf` action.\n\nThe PDF reuses the same `hrm.attendance.monthly_summary` verdict +\npayroll figures the in-app view shows (so the two never drift), then\nwraps them in the canonical `@helios/documents` letterhead chrome.\nLayout: employee + period strip, 5-up KPI strip (Present / Late /\nAbsent / Leave / Overtime), payroll-basis panel with the headline\n\"Payable days\" emphasised in success-green, a 7-column calendar\nheatmap (Mon→Sun) with the same status colours as the in-app grid, a\nfiltered exceptions table (only payroll-relevant rows so the page\nstays single A4), and an assumptions footer recording the weekend\nmask + late grace + late-penalty rule the kernel applied. Filename\nencodes the employee number + period so a folder of downloads sorts\nnaturally.\n\nThe \"Print\" button label is now \"Download PDF\" and shows a \"Rendering…\"\nstate while the action runs.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:10:08.284Z","updatedAt":"2026-06-13T08:10:08.284Z"},{"id":"4223342d-52cf-4f19-98db-591e7ea8191a","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-readiness-and-self-hosted-checkout","type":"changed","scope":"web","summary":"Signup plan checkout stays on the self-hosted /pay/c page (Stripe Elements) instead of bouncing to Stripe; CompleteStep polls a real readiness probe and shows actual seed progress.","body":"Three coordinated changes that put a real, server-driven finishing\nanimation at the end of the signup wizard:\n\n**1. Self-hosted checkout for signup-plan purchases.**\n`payments.signup.create_plan_checkout` used to request `mode:\n'hosted'` from the payments session-create action, which produced\na `publicUrl` pointing at the provider's hosted page (Stripe\nCheckout). The wizard then `window.location.href`'d to that URL,\nyanking the buyer out of our brand chrome mid-signup. Now the\none-time-charge path requests `mode: 'elements'` — the user stays\non `/pay/c/<id>` with Stripe Elements mounted on our page. The\nsubscription path (Stripe Checkout subscriptions) still uses\nhosted because that's the only mode the API supports for\nrecurring billing in their flow.\n\n**2. New `saas.organization.signup_readiness` action.**\nA cheap probe (~7 indexed queries against small per-org tables)\nthat returns an ordered list of readiness items:\n- `workspace` — `organizations` row exists for the active org\n- `membership` — actor has a `memberships` row in it\n- `prefs` — `organizations.workspace_prefs` has `enabledModules`\n- `hrm_defaults` — `hrm_departments` row exists (only if HRM\n  enabled)\n- `crm_pipeline` — `pipelines` row exists (only if CRM enabled)\n- `payments_routing` — `payment_routing_rules` row exists (marked\n  optional — only fires on deployments with a platform provider)\n\nReturns `{ ready, total, done, items }` where `ready` is the\nconjunction of every non-optional check.\n\n**3. CompleteStep rewritten to poll readiness.**\nThe old `CompleteStep` was a 2.5-second hard-coded timer that\ndeclared \"workspace ready 🎉\" regardless of whether the\norg-create event chain had finished firing every seed. Now it\npolls the new readiness action every 800 ms and renders each\nitem as a live row in `<WorkspaceProcessingScene>` (pulsing orb\nanchor + filling progress bar + per-row pending → spinning →\ndone state). The dashboard redirect fires the moment `ready`\nflips true, with a 25 s safety timeout so a genuinely stuck seed\nnever strands the user.\n\nThe fake 4.2 s timer interjection in ModulesStep is gone —\nthe wizard navigates straight to InviteTeamStep, and the real\nprocessing experience lives at the end of the flow where it\nbelongs.\n\n`<WorkspaceProcessingScene>` keeps a cosmetic-fallback mode for\ncallers that don't have a readiness probe to thread through,\nbut the wizard now uses the server-driven path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:10:08.528Z","updatedAt":"2026-06-13T08:10:08.528Z"},{"id":"0fae7280-74e5-4fcc-a569-dcd9e3e88e9f","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"paid-signup-routes-through-readiness","type":"changed","scope":"web","summary":"Paid-plan signup auto-redirects from /pay/return into /signup?step=complete so the readiness scene polls real seed progress before landing the buyer on the dashboard.","body":"The paid-plan signup success scene at `/pay/return/<sessionId>`\nshowed \"Payment successful\" and a button labelled \"Go to your\nworkspace →\" linking straight to `/`. That left the buyer\nlanding on the dashboard while the cross-module seed chain\n(HRM departments, CRM pipeline, payments routing) was still\nfiring — the dashboard rendered with half-empty tables and a\nflash of \"no data yet\" states.\n\nNow the org-creating success scene auto-redirects to\n`/signup?step=complete` after a short 2.4-second celebration.\nThat route's `CompleteStep` polls\n`saas.organization.signup_readiness` every 800 ms and renders\nlive per-row progress in `<WorkspaceProcessingScene>` — exactly\nthe same well-designed end-of-signup experience the free path\nalready gets. Once `ready` flips true the buyer bounces to the\ndashboard with everything seeded.\n\nThe non-org-creating path (regular invoice payment) keeps the\nexisting 4-second auto-redirect to the invoice. The shorter\ndelay on the org-creating side avoids stacking two long\ncelebrations back-to-back (the success scene + the readiness\nscene).\n\nA small CTA button still renders inline so users can skip the\nauto-redirect — clicking \"Continue to your workspace →\" goes\nto the same `/signup?step=complete` destination.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:10:08.551Z","updatedAt":"2026-06-13T08:10:08.551Z"},{"id":"9eec1f1f-aa96-4d3e-9ff6-687d2ded2078","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-gateway-picker","type":"added","scope":"payments","summary":"Multi-provider gateway picker on the signup plan step — buyer chooses Stripe / Razorpay / Paddle etc. when the platform has multiple gateways enabled.","body":"Phase C of the signup hardening plan. The platform-level payment\ngateway management already supports multiple concurrent providers\n(Stripe + Razorpay + Paddle + 16 others), but the signup wizard\nsilently routed every paid checkout through the routing engine's\ndefault-resolver — the buyer had no say in how they paid.\n\n**New action `payments.platform.providers.list_active_for_signup`.**\nPublic read that returns the platform tenant's active, non-test,\nnon-deleted providers filtered by the descriptor's capabilities\n(currency support + subscriptions vs one-time). Returns ONLY\ndisplay-safe fields: id, kind, operator-chosen label,\ndefaultCurrency, subscriptions flag. Never leaks the encrypted\nconfig, webhook secret, or health score.\n\n**Buyer-facing `<GatewayPicker>` on `PurchasePlanStep`.** Three\nrender shapes:\n\n- **Zero providers** — picker hides; the existing \"no payment\n  provider configured\" message takes over.\n- **One provider** — quiet single-line \"Paying via {Provider}\"\n  pill. Auto-selected; no choice to make.\n- **Two+ providers** — branded radio cards with the operator's\n  label, default currency, and a \"Subscriptions\" tag where\n  applicable. The buyer's choice is required before continuing\n  (PurchasePlanStep's onContinue blocks with an inline error\n  if no provider is picked).\n\n**`providerId` threaded through to checkout.** Added to\n`SignupPlanCheckoutInput`. The handler revalidates the chosen id\nis still active + non-deleted on the platform tenant (defends\nagainst stale picker payloads across a multi-tab signup race)\nand falls back silently to the routing engine's default if the\nid doesn't match an active provider. The id never leaves the\nserver unverified.\n\n**Persisted in the wizard draft (`draft.plan.providerId`).** A\nBack/Forward between the plan and workspace steps now restores\nthe buyer's choice instead of resetting it.\n\nSubscription-mode signups still bind to the sub-capable plan's\npre-synced provider (subscription pricing tied to provider price\nrows); the picker shows the choice but doesn't override it for\nthose. The one-time-charge path honours the picker verbatim.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:47:04.659Z","updatedAt":"2026-06-13T08:47:04.659Z"},{"id":"cb32a62c-ce84-4652-ac35-ba86b8b31877","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-hardening-phase-b","type":"fixed","scope":"web","summary":"Signup logic-correctness fixes — 25s timeout anchors to mount, scene placeholder kills the cosmetic-jump, payments_routing is conditional, invite failures are visible.","body":"Phase B of the signup hardening plan\n(`docs/plans/SIGNUP_AUDIT_AND_HARDENING_PLAN.md`).\n\n**B.1 — Mount-anchored 25 s timeout.** The CompleteStep safety\nfallback depended on `readiness.data?.ready` in its deps, so\nevery poll reset the timer. A genuinely stuck seed chain\nreturning fresh-but-incomplete data would defer the bail-out\nindefinitely. Now scheduled once via a ref-based wall-clock\nthat's mount-time-anchored.\n\n**B.2 — Scene placeholder kills cosmetic-jump.** With the\nprocessing scene running the cosmetic ticker between mount and\nfirst-poll-resolve, items briefly checked then un-checked when\nreal data landed. CompleteStep now passes a synthetic\nplaceholder progress immediately so the scene runs server-driven\nmode end-to-end.\n\n**B.3 — `payments_routing` only on revenue-emitting workspaces.**\nWas previously surfaced as optional on every workspace, showing\n\"Payments routing seeded\" pending forever for workspaces that\nhave no revenue path. Now only included when the workspace\nenabled at least one revenue module (sales / clients / crm /\naccounting), and when included it gates readiness because those\nworkspaces actually need it.\n\n**B.4 — Stable item ordering.** Items built in a fixed sequence\nso subsequent polls never shuffle row order.\n\n**B.5 — Per-invite visibility on InviteTeamStep.** The previous\n`Promise.allSettled` silently swallowed individual invite\nfailures. Users entering 5 emails with 2 failing thought all 5\nwent out. Now failures (rejection OR returned `{ error }`) are\ncounted, surfaced inline as both a toast and an inline error\nstrip, and block the navigate so the user can either remove the\nfailed rows + retry or explicitly skip.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:47:05.380Z","updatedAt":"2026-06-13T08:47:05.380Z"},{"id":"97550280-5413-45de-b3b5-9037ba486199","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-hardening-phase-d","type":"performance","scope":"web","summary":"Signup-readiness polish — exponential-ish poll backoff, tab-background pause, setup_state surfacing, icon-key fallback warning.","body":"Phase D of the signup hardening plan (final phase).\n\n**D.1 — Polling backoff.** CompleteStep used to poll every 800\nms uniformly. The free-plan org-create chain typically resolves\nin 1-3 s and paid in 3-8 s, so the slow tail wasted queries —\n~84 queries for a single slow signup. New tiered cadence: 600\nms for the first 5 s (catches the common case immediately),\n1.5 s for the next 7 s, 3 s thereafter. Caps when `ready` flips\ntrue.\n\n**D.2 — Tab-background pause.** The previous function-form\n`refetchInterval` didn't actually pause polling when the tab\nwas backgrounded — `refetchIntervalInBackground: false` only\napplies when the interval is a static number. Now the function\nexplicitly returns `false` whenever `document.visibilityState`\nis `'hidden'`, so a tab switched to a background tab stops\nhammering the action until the user comes back.\n\n**D.3 — `setup_state` row on readiness.** The dashboard's\norg-setup banner reads `organizations.setup_state.modules`\nseparately from the readiness probe, which meant the two could\nsilently diverge if the wizard's `setup.complete_step` write\nfailed. The probe now surfaces an optional `setup_modules_stamped`\nrow that mirrors the banner's read. Optional because the\nworkspace IS fully usable without the stamp; but visibility\nprevents user confusion if the banner persists post-redirect.\n\n**D.4 — Icon-key fallback warning.** The processing scene's\nicon map fell back silently to a generic glyph for unknown\nreadiness keys, which masked maintenance regressions when the\nserver added a key the client didn't recognise. Now logs a\nconsole warning in dev (suppressed in production) so adding a\nnew readiness key is hard to miss.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:47:05.462Z","updatedAt":"2026-06-13T08:47:05.462Z"},{"id":"96350a27-5d8a-4011-9f13-26912cc02ddc","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-from-session-coercion","type":"fixed","scope":"web","summary":"Fix \"Invalid input\" error on /signup?step=complete after a successful paid checkout — fromSession schema now accepts the auto-coerced number from TanStack Router.","body":"Hotfix for a staging-server crash on the paid-signup return path.\n\nAfter a successful payment, `/pay/return/<id>` auto-redirects to\n`/signup?step=complete&fromSession=1&orgId=<uuid>`. TanStack\nRouter's `validateSearch` auto-coerces purely-numeric query\nvalues, so `?fromSession=1` arrives at the schema as the number\n`1`, not the string `\"1\"`. The previous `z.string()` rejected it\nwith:\n\n```\n[{ \"expected\": \"string\", \"code\": \"invalid_type\",\n   \"path\": [\"fromSession\"], \"message\": \"Invalid input\" }]\n```\n\n…which surfaced as the \"Page failed to load — something went\nwrong on our end\" full-page error. The entire post-checkout\nlanding was broken.\n\nThe schema now accepts string / number / boolean and transforms\nto a boolean. The downstream consumer in `CompleteStep` reads\n`search.fromSession` as a truthy value (driving the\n\"Finalising your subscription\" copy override) so the consumer\nshape is unchanged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:20.983Z","updatedAt":"2026-06-13T09:22:20.983Z"},{"id":"16b90a11-79d1-4d15-9a21-9e44f5e363f6","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"signup-hardening-phase-f","type":"security","scope":"payments","summary":"Signup hardening Phase F — buyer binding on /pay/c for signup sessions, plus closure on FLOW-11 (false-positive) and LOG-9 (architecture documented for follow-up).","body":"Phase F closes the originally-deferred audit items.\n\n**F.1 — Buyer binding on `/pay/c` for org-creating sessions\n(SEC-2 fix).** A leaked `signup_plan_purchase` session id used\nto be payable by anyone with the URL: a third party could\ncharge their own card and the\n`saas-on-payments-session-confirmed` subscriber would create an\norg with the original buyer's user id as owner (from\n`pending_org_payload.ownerUserId`). The original buyer would end\nup with a paid workspace they never asked for.\n\n`payments.session.get` now surfaces `expectedBuyerUserId` —\nextracted from the session's `pending_org_payload.ownerUserId`\nfor org-creating sessions (`signup_plan_purchase`,\n`marketing_plan_purchase`); null for everything else.\n`/pay/c/<sessionId>` checks the current actor's `/api/me` user\nid against this and:\n\n- **Signed out** → \"Sign in to continue this purchase\" with a\n  return link back to `/pay/c/<id>` after sign-in.\n- **Wrong actor** → \"This payment belongs to a different\n  account\" with a sign-out + switch-account CTA chain. The\n  checkout form does not render.\n- **Right actor** → Falls through to the normal checkout\n  content (HostedCheckout or ElementsCheckout).\n\nNon-org-creating sessions (invoice share links, recruitment\npublic tokens, etc.) skip the gate — those flows intentionally\nlet whoever clicks the link pay.\n\n**F.2 — FLOW-11 closed as not-a-bug.** The original audit\nflagged \"cross-tab signup can overwrite each other's draft via\nsessionStorage.\" Reviewed: `window.sessionStorage` is per-tab by\nbrowser spec, so two signup tabs do NOT share the draft. The\nauditor confused it with `localStorage`. We use localStorage\nseparately for `helios.workspace.<orgId>.prefs.v1` (post-signup\nprefs keyed by orgId, which is correct — once the orgId\nexists, tabs reading it should agree). Documented inline in\n`signup-state.ts` so the next reviewer doesn't re-raise the same\nfinding.\n\n**F.3 — LOG-9 architecture documented.** Self-hosted\nsubscription checkout via Stripe's SetupIntent flow is a real\nin-app UX upgrade but crosses provider adapter, webhook, session,\nand subscription-creation boundaries. Sketched the end-to-end\ndesign in `docs/plans/SIGNUP_AUDIT_AND_HARDENING_PLAN.md`\nsection 4: new `'elements_subscription'` session mode, new\nprovider capability flag, `setup_intent.succeeded` webhook\nbranch that creates the subscription server-side, plus the\nfailure-handling open question. Estimated effort 3-5 days for\nStripe + 1 day per additional sub-capable provider; not\nblocking because the hosted fallback works correctly today.\n\nWith Phase F, every audit finding marked REAL or HIGH is closed.\nThe plan doc's status table is updated end-to-end.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T09:22:21.018Z","updatedAt":"2026-06-13T09:22:21.018Z"},{"id":"acc7a1b4-ce7c-47fc-81cb-82db28d715b8","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"paid-signup-full-wizard-and-celebration-polish","type":"changed","scope":"web","summary":"Paid signup now walks through Modules + Invite steps before the readiness scene; CompleteStep celebration animation is staggered, springy, and holds long enough to feel intentional.","body":"Phase L of the signup-hardening initiative.\n\n**L.1 — Paid signup goes through the full wizard.** Before this\nchange, `/pay/return` auto-redirected to `/signup?step=complete`\nthe moment the payment confirmed, **skipping** the modules\npicker (where the Demo data checkbox lives) AND the\ninvite-team step. Paid buyers were dumped at the readiness\nscene with whatever module preset their workspace's primaryUse\nimplied — no chance to adjust, opt into demo data, or send\ninvitations.\n\nNow `/pay/return`'s SuccessScene redirects to\n`/signup?step=modules&fromSession=1&orgId=<id>`. ModulesStep\ndetects the search-param `orgId` and treats it exactly like a\nprior-run `draft.createdOrgId` — skips `authClient.organization.create`\n(the saas subscriber already ran it from\n`pending_org_payload`), threads everything else through (prefs\nupsert, setup-state stamp, demo seed, setActive). Invite step\nthen runs normally, and CompleteStep handles the readiness\nanimation at the end. `clearDraft()` moved from\n`/pay/return` (premature) to `CompleteStep`'s terminal navigate\nso a refresh during the post-payment wizard steps doesn't\nstrand the buyer.\n\nThe same `searchOrgId` override also relaxes ModulesStep's\n\"no workspace draft → bounce to workspace step\" guard: when\nthe buyer arrives with an explicit orgId, the missing-draft\ncase is expected and the step proceeds. The prefs upsert\nsilently omits `industry`/`teamSize`/`primaryUse` if the draft\nis absent (paid path reopened on a fresh tab) — those are\ninformational and can be set later from Settings → Organization.\n\n**L.2 — Celebration scene polish.** `WorkspaceProcessingScene`\ngot three coordinated upgrades to fix the \"preloads, sticks on\none step, then yanks away\" feedback:\n\n- **Staggered row entrance** — rows reveal sequentially via\n  motion.li with a 0.08s × index delay (capped at the first 4\n  rows so a long catalog doesn't read as choppy). When the\n  first poll lands with several items already done, they\n  cascade in instead of dumping all at once.\n- **Springy row-state transitions** — the icon swap (pending →\n  spinning → check) runs through AnimatePresence with a 460-\n  stiffness spring for the check, so the completion moment\n  feels solid instead of static. The label colour + weight\n  also transitions in motion (not CSS) for consistent timing.\n- **Celebration anchor + 1.6s settle** — when `progress.ready`\n  flips true, the pulsing orb is replaced with a\n  `CheckCircle` (success colour) via AnimatePresence; the body\n  copy becomes \"Your workspace is ready — taking you in\n  now.\" CompleteStep then waits 1.6 s (was 600 ms) before\n  navigating, so the completion lands as a beat instead of a\n  yank. Server work is already done at this point; the delay\n  is purely visual punctuation.\n\nProgress bar transition also moved from `easeOut` to a\nspring (stiffness 120, damping 22) so it settles into 100%\ninstead of snapping.\n\nHonours `prefers-reduced-motion` end-to-end — animations\nfall back to instant transitions; the row reveal + state-\nchange semantics still play because they communicate real\nprogress.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T13:25:39.636Z","updatedAt":"2026-06-13T13:25:39.636Z"},{"id":"1c16c304-57ef-4222-92aa-1be3441f03a2","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-4-multi-entry","type":"fixed","scope":"hrm","summary":"Per-entry attendance verdict on the table view + entry detail panel now judges half-day against the day's TOTAL worked minutes, not the single-entry slice.","body":"Phase 4 of the HRM time-attendance audit closes the per-entry side of\nthe multi-entry bug. The kernel side (`hrm.attendance.monthly_summary`\n+ `org_overview`) was fixed in Phase 5. This commit fixes the\nper-entry surfaces that still passed a single entry's\n`durationSeconds` to `computeAttendanceStatus`, so a morning 3h entry\non a day with a 5h afternoon entry was misclassified `half_day` even\nthough the day's TOTAL was a full 8h.\n\nTwo surfaces fixed:\n- The list / table view (`hrm.time_entry.list`) now pre-aggregates a\n  page-scoped `(employee, date) → totalMinutes` map and passes that\n  to the half-day check. Each entry's lateness stays per-entry; only\n  the half-day decision now uses the day total.\n- The entry detail panel (`hrm.time_entry.get`) issues a tight\n  same-day siblings query (≤24h range, same employee) and uses the\n  summed total for the same check.\n\nThe calendar pre-pass already used `agg.raw / 60` (day total) so it\nwas correct; verified no change needed there.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:58.173Z","updatedAt":"2026-06-25T01:24:58.173Z"},{"id":"e1240a8b-48ad-4e8b-9bbf-26e0b6b200ce","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-6-half-day","type":"fixed","scope":"hrm","summary":"Attendance report now honors the org-configured half-day thresholds from Settings → Clock policy (worked + lateness branches).","body":"Phase 6 of the HRM time-attendance audit. The attendance kernel was\nhard-coding the half-day threshold as `scheduledMinutes / 2` instead\nof reading the operator's configured `halfDayThresholdMinutes` from\nthe clock policy, and the related \"if arrival is THIS late, count\nit as half-day\" rule (`halfDayLateThresholdMinutes`) wasn't modeled\nat all. Both branches now plumb through from the policy and honor\nthe schema's `0 = disabled` convention.\n\nA day worked under the configured floor → half-day. A day where\narrival was past the configured lateness threshold → half-day even\nif worked time is full. A day past the late-grace but under the\nlateness threshold → late. Otherwise → present. Each branch is\ndisable-able by setting its threshold to 0. Both attendance\nsurfaces (single-employee + org-wide overview) plumb the same\nvalues from the policy.\n\nFour kernel tests pin: explicit threshold overrides the\nscheduledMinutes/2 default, 0-disables the worked-time check,\nlate-flip-to-half-day rule fires past the configured threshold,\n0-disables the late-flip rule.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:58.781Z","updatedAt":"2026-06-25T01:24:58.781Z"},{"id":"2bfdcc8e-36f1-4422-bce2-8092a0b8b6ba","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-5","type":"fixed","scope":"hrm","summary":"Missed-shift days no longer bank overtime from out-of-shift work; multi-entry days now sum all closed entries. Both attendance surfaces share one rule.","body":"Phase 5 of the HRM time-attendance audit\n(`docs/plans/HRM_TIME_ATTENDANCE_AUDIT.md`). Two operator-reported\ndata bugs closed:\n\n1. **Missed-shift OT.** A day where the employee didn't cover their\n   scheduled in-shift minutes (within the org's\n   `missedShiftToleranceMinutes` tolerance) was banking overtime\n   credit for any out-of-shift work — against the documented rule.\n   Both attendance actions (single-employee + org-wide overview)\n   now route their per-day OT through one shared\n   `gateDayOvertime` helper that returns 0 OT on missed-shift days,\n   matching the per-entry `rollupCoverageAware` surface in\n   `actions/time.ts`.\n\n2. **Multi-entry day under-count.** The aggregation loop already\n   summed `workedMinutes` across closed entries but the per-entry\n   OT calc inside the loop was overwriting `overtimeMinutes` on\n   every iteration instead of accumulating coverage. The new\n   shape tracks `inShiftMinutes` cumulatively across entries and\n   the OT gate runs AFTER aggregation, so two short shifts and\n   a long lunch resolve correctly.\n\nCross-midnight shifts fall through to the unguarded math for now —\nthe wrap-aware coverage calc is Phase 5.1. Phases 1–4 + 6–10 of\nthe audit are tracked in the audit plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:58.790Z","updatedAt":"2026-06-25T01:24:58.790Z"},{"id":"19a16b0b-6a6e-4970-950f-d3ebe5bf76ba","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-self-service","type":"added","scope":"hrm","summary":"Employees see their own monthly attendance report on /hrm/me; cross-employee read leak closed in two attendance actions.","body":"The attendance report was previously HR-only — the action's policy\ngate accepted `hrm:time_entry:read:own` but the handler ignored that\nscope and would return any employee's data to anyone with the\npermission. Closed the leak: when the actor's only permission is\n`:own`, the single-employee action refuses cross-employee reads and\nthe org-overview action narrows its result to the actor's own row.\n\nWith the leak closed, the report is safe to surface to employees.\nThe `/hrm/me` self-service page now mounts `<MonthlyAttendanceReport>`\nunder a \"My attendance\" heading — the same report payroll consumes\nfor monthly deductions, so workers can verify their figures (present\ndays, late days, payable days) before payroll runs.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:59.028Z","updatedAt":"2026-06-25T01:24:59.028Z"},{"id":"b3bce64e-ae4d-42f7-96f8-00f0c93e8830","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"payroll-attendance-deduction","type":"added","scope":"payroll","summary":"Payroll runs now apply attendance-derived deductions (absent / unpaid-leave / late-penalty days) from the HRM monthly summary.","body":"The HRM attendance monthly summary produced \"payable days\" figures but\nthe payroll calc engine never consumed them — every run paid the full\nper-period salary regardless of absences. Now `payroll.run.calculate`\ncalls `hrm.attendance.org_overview` once per run for the period's date\nrange, indexes the result by employee, and threads each employee's\ndeduction figures into the calc snapshot. The engine appends an\n\"Attendance adjustment\" pre-tax deduction line proportional to\n`deductionTenths / (baseDays × 10)` × the base-salary line.\n\nThe line is pre-tax (the employee doesn't pay tax on money they didn't\nearn), salary-only (hourly employees already have actual attendance\nreflected in `hoursWorked`), and carries a breakdown footnote\n(\"3.5 days = 2 absent + 1 unpaid leave + 0.5 late penalty\") so payroll\nand the employee can audit each component. When attendance is\nunavailable (e.g. the HRM action isn't loaded in a stripped runtime)\nthe calc proceeds without a deduction — preserving prior behaviour.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:59.286Z","updatedAt":"2026-06-25T01:24:59.286Z"},{"id":"d30681e6-fd39-4dae-a0d9-556e5e036b7d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-9-relaxation","type":"fixed","scope":"hrm","summary":"Monthly attendance report now honors the org's lateness relaxation budget (forgiven late minutes don't classify as late or trigger late-penalty deductions).","body":"Phase 9 of the HRM time-attendance audit. The org's clock policy\ncarries a `relaxationMinutesPerMonth` budget for forgiving small\nlateness, and the per-entry attendance verdict\n(`computeAttendanceStatus`) honored it. The newer monthly attendance\nkernel didn't, so workers were classified `late` and dinged with\nlate-penalty deductions for minutes the org had explicitly\nconfigured to forgive.\n\nThe kernel now walks days chronologically and burns the monthly\nbuffer against any lateness ABOVE the grace window before deciding\nlate/half-day status or computing the late-penalty deduction:\n\n- New `relaxationMinutesPerMonth` kernel input. 0 (default)\n  disables the buffer entirely (no change in behaviour for orgs\n  that didn't configure it).\n- New `DayBreakdown.effectiveLateMinutes` + `relaxationApplied`\n  fields. Raw `lateMinutes` is preserved for audit.\n- Classifier uses `effectiveLateMinutes` for the late + half-day\n  branches.\n- Late-penalty math uses `effectiveLateMinutes` — forgiven late\n  no longer costs the employee pay.\n\nBoth attendance actions (single-employee + org-wide overview)\nplumb `relaxationMinutesPerMonth` from the org's clock policy.\nPer-(month, employee) running tallies are maintained within a\nsingle call; cross-month windows reset the buffer on the month\nboundary.\n\n4 new kernel tests pin: budget burns chronologically across\nmultiple days, classifier uses effective not raw, 0 disables,\nlate-penalty respects the forgiven minutes.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["knee-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:59.028Z","updatedAt":"2026-06-25T01:24:59.028Z"},{"id":"c6e719b8-3443-4d99-aaae-5ef82fc8116d","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-holiday-bulk-create","type":"added","scope":"hrm","summary":"Holidays form supports date ranges, multiple dates, and the importer surfaces Islamic holidays without picking a country.","body":"The HRM holidays form only let admins add one holiday at a time, which\nturned a 3-day Eid into three round-trips and a 12-day annual closure\ninto a tedious manual loop. The form now offers three date modes\n(Single / Date range / Multiple dates) and submits via a new\n`hrm.holiday.bulk_create` action that inserts one row per date with a\nshared template, reporting the per-date verdict so existing dates are\nreported in `skipped[]` rather than failing the whole batch. Re-runs\nare idempotent.\n\nThe import-from-catalog dialog was also extended: previously the\ncatalog only returned Islamic / Hindu / Buddhist / etc. holidays after\nthe admin picked a specific country, because the curated core has no\nreligion-tagged fixed-date entries. Now selecting a religion **without**\na country aggregates entries across the representative observing\ncountries (Islamic = SA / AE / PK / EG / ID / MY / TR / IR / BD / KW /\nQA / BH / OM / JO / MA / DZ / TN; Hindu = IN / NP / MU; Buddhist =\nTH / LK / MM / BT / KH / LA; Jewish = IL; etc.), dedupes by name +\ndate, and presents the unified list for one-click import.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:59.286Z","updatedAt":"2026-06-25T01:24:59.286Z"},{"id":"1f16370c-f5e2-481c-826b-c0217f74d6ef","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"recruitment-candidates-panel-kanban-polish","type":"changed","scope":"recruitment","summary":"Job detail page gains a candidates panel with kanban + list views; main board's kanban was redesigned and polished.","body":"The job detail page used to link out to the applications board for any\ncandidate work. It now hosts an embedded **Candidates** panel with two\nviews — a compact stage-swimlane kanban (top five per stage with a\n\"+N more\" link into the deep board) and a tabular list — both scoped\nto the job. Read-only on purpose; drag-and-drop and mutations live on\nthe full board, reachable via the panel's \"Open full board ↗\" button.\n\nThe main applications kanban was visually refreshed: each column\nheader is now stage-tinted instead of carrying a single accent rule,\nthe count chip echoes the stage colour, columns scroll when long\ninstead of growing the page, and cards have a 2-pixel stage-tinted\nleft rail that anchors them to their column. Card hover state lifts\na touch more and casts a softer shadow; the dragging affordance gets\na subtle rotation. The action menu now reveals on hover/focus so\nresting cards stay calm, tag overflow surfaces a `+N` chip (with the\noverflowed tags on hover via title), and the drop-target ring uses a\ngentler offset ring instead of a solid border.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T01:24:59.548Z","updatedAt":"2026-06-25T01:24:59.548Z"},{"id":"d21a542a-9889-4d64-80b5-bc9627509a01","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-1-visuals","type":"changed","scope":"hrm","summary":"Attendance status colours are unified across the timesheet table, monthly report, and entry detail so `late` looks the same red/amber everywhere.","body":"Phase 1 of the HRM time-attendance audit. The user flagged that\n\"the timesheets, the calendar, and the detailed entry panel and\nthe attendance report show really different data, it visualize\ndata very different. at few place it misses the late visualization.\"\nThe root cause was two independent visual maps — the per-entry\ntable mapped `on_time/late/half_day/absent/no_shift` to one set\nof tones, the monthly report mapped its richer per-day enum\n(`present/late/half_day/absent/leave_paid/leave_unpaid/\nhalf_day_leave/holiday/weekend/not_scheduled/future`) to another\nset with subtly different colours.\n\nA new shared lib (`apps/web/src/lib/attendance-visual.ts`)\nprovides the canonical map. Both enums route through it — the\nper-entry `on_time` and per-day `present` share the same green\nrow, both enums' `late` share the same amber row, etc. Every\nattendance surface (timesheet table, monthly report, entry-detail\nsheet, calendar cells) reads from one source so the same status\nrenders identically no matter which page surfaces it.\n\nPhases 2 (absent), 3 (holidays + weekends + paid leave), 7 (breaks),\nand 8 (half-day) of the audit are queued. Phases 4, 5, 6, 9, 10\nalready landed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T14:24:11.900Z","updatedAt":"2026-06-25T14:24:11.900Z"},{"id":"c7bdb379-2ec9-4f53-aefd-6dc6e6a671bd","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-10-tz","type":"fixed","scope":"hrm","summary":"Attendance actions bucket entries by the org's calendar day (not UTC), so non-UTC tenants see entries on the correct day.","body":"Phase 10 of the HRM time-attendance audit. The two attendance\nactions (`hrm.attendance.monthly_summary` + `org_overview`) used\n`e.startedAt.toISOString().slice(0, 10)` to bucket each entry into\na calendar day. That's a UTC slice — for a non-UTC org, a 23:00\nlocal clock-in lands on the wrong day. Karachi (UTC+5) workers\nwere watching tonight's entries roll into tomorrow's bucket;\nSão Paulo (UTC−3) workers had a similar drift at the boundary in\nthe opposite direction.\n\nBoth actions now load the org timezone (`getOrgTz`) once at the\ntop of the handler and route every per-day bucketing through\n`isoDayInTz(instant, orgTz)`:\n\n- per-entry attribution (the bucketing inside the entry-aggregation\n  loop)\n- the `asOf` calendar day used by the kernel to mark days as\n  `future`\n\nThe kernel itself is unaffected — it takes pre-bucketed maps. Leave\ndate iteration (calendar-only `date` columns) keeps the existing\nUTC-midnight step since those values have no time component.\n\nPhases 1–3, 7–8 remain queued in the audit plan.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T14:24:12.696Z","updatedAt":"2026-06-25T14:24:12.696Z"},{"id":"e3f792e1-169e-47de-8eac-8b466070046e","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-2-3-holiday-leave","type":"changed","scope":"hrm","summary":"Calendar action surfaces holiday + paid-vs-unpaid leave on every day row so the UI cell can render the unified tone.","body":"Phases 2 + 3 of the HRM time-attendance audit. The `/hrm/time`\ncalendar action had no holiday surface at all — a public-holiday\nday on the cell looked like an unexplained \"no expectation\"\nweekday with the user wondering why the worker wasn't dinged.\nAnd the existing `leave` annotation carried the policy name but\nnot the paid/unpaid flag, so paid PTO and unpaid leave rendered\nthe same.\n\nSchema (`CalendarDayRow`):\n- `leave.paid: boolean` — already in the kernel; now flows through\n  the calendar's day row too.\n- `holiday: { name, isOfficeClosure } | null` — surfaces active\n  office-closure holidays for the day.\n\nAction (`hrm.time_entry.calendar`):\n- New `hrm_holidays` query (active rows in window, first-per-date\n  wins).\n- New `holidayByDay` map; surfaced on every day row.\n- Leave query joins `leavePolicies.paid` so the `paid` boolean is\n  available per day.\n\nThe cell rendering picks up these fields automatically when the\ncalendar consumer reads them — `<MonthlyAttendanceReport>` and the\nin-app calendar both consume `CalendarDayRow` via the shared\nattendance-visual map landed in Phase 1.\n\nPhases 7 (breaks paid vs unpaid) + 8 (half-day unification) remain\nqueued. Phases 1 / 4 / 5 / 6 / 9 / 10 already landed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T14:24:12.711Z","updatedAt":"2026-06-25T14:24:12.711Z"},{"id":"c28f6c43-687d-4df4-8b59-b3bb405a65d3","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-7-breaks","type":"fixed","scope":"hrm","summary":"Attendance kernel now honors the org's paid-vs-unpaid break setting when judging half-day so unpaid breaks correctly count against worked time.","body":"Phase 7 of the HRM time-attendance audit. The attendance kernel\naccepted a `breakMinutes` field in its `workedByDate` map but the\ntwo attendance actions never populated it — `timeEntryBreaks` was\nqueried by the per-entry surface (`computeAttendanceStatus`) but\nnot by the monthly kernel. Two consequences:\n\n- `totalBreakMinutes` in the report was always 0.\n- The half-day worked-time check compared GROSS worked against the\n  threshold even when the org configured `breakPaid = false`. A\n  worker with 6h gross + 2h unpaid break (4h net) reading as\n  \"present\" on the report while the per-entry verdict in the table\n  flagged it `half_day`.\n\nKernel (`lib/monthly-attendance.ts`):\n- New `breakPaid` input (default `true` = paid breaks = gross worked\n  feeds the check). When `false`, the half-day check uses\n  `workedMinutes − breakMinutes` (net), matching `computeAttendance\n  Status`'s `effectiveWorkedMinutes` math so per-entry and per-day\n  agree.\n\nActions (single + org-overview):\n- Query `time_entry_breaks` for the closed-entry ids in the window;\n  sum durations into `breakSecondsByEntry`.\n- Fold each entry's break into the per-day bucket alongside worked.\n- Plumb `clockPolicies.breakPaid` into the kernel call.\n\n2 new kernel tests pin both directions: unpaid breaks + 6h gross\nreads as half-day under a 4.5h threshold, paid breaks at the same\nfixture read as present.\n\nPhase 8 (half-day classification consistency across surfaces) is\nthe only remaining audit phase.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T14:24:13.018Z","updatedAt":"2026-06-25T14:24:13.018Z"},{"id":"d11540e6-b106-4ff0-9e4d-c20f378e2844","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-5-1-cross-midnight","type":"fixed","scope":"hrm","summary":"Cross-midnight night shifts now run through the missed-shift OT gate with wrap-aware coverage math; post-midnight entries attribute to the correct shift day.","body":"Follow-up to Phase 5 of the HRM time-attendance audit. The Phase 5\ngate suppressed OT credit on missed-shift days for same-day shifts\nbut fell through to the unguarded `worked − scheduled` calc when\nthe shift crossed midnight, because the per-entry overlap math\ncouldn't wrap. A Friday-night shift (18:00 → 02:00 Sat) had its\npost-midnight hours mis-attributed to Saturday and was banking\nphantom OT on missed shifts.\n\nNew `lib/shift-window.ts`:\n- `resolveShiftWindow(slot)` translates a scheduled slot into the\n  actual UTC `[shiftStartMs, shiftEndMs)` instants. Cross-midnight\n  windows wrap to the next calendar day; same-day shifts are a\n  special case of the same math. Honours per-shift timezone with\n  fallback to org tz.\n- `entryShiftOverlapMinutes(entryStart, entryEnd, window)` does the\n  standard half-open interval intersection. Same maths whether the\n  shift wraps or not.\n- 11 unit tests pin both code paths.\n\nBoth attendance actions (single + org-overview):\n- Widen the slot fetch ONE day backwards so a Friday-night shift is\n  visible when the report window starts Saturday — post-midnight\n  entries can re-attribute back to Friday's bucket.\n- Schedule map carries the resolved `window: ShiftWindow | null`.\n- Entry loop chooses the bucket day by asking which day's wrap-\n  aware window contains the entry's start (today first, then\n  yesterday's cross-midnight). Mirrors the `rollupCoverageAware`\n  surface so the two reconcile.\n- Lateness computed in absolute UTC: `entryStartMs - shiftStartMs`.\n  Cross-midnight: a Sat 01:00 clock-in for a Fri 18:00 → Sat 02:00\n  shift is 7h late, not −17h late.\n- In-shift overlap uses `entryShiftOverlapMinutes` for ALL shifts.\n\nThe `gateDayOvertime` helper no longer special-cases cross-midnight;\nthe caller now feeds wrap-aware `inShiftMinutes` so the coverage\ndecision is identical regardless of whether the shift wraps. Test\nupdated to assert both directions (covered + missed) for cross-\nmidnight.\n\n37 lib tests pass (shift-window + day-overtime-gate + kernel).\nHRM module typechecks. Closes the cross-midnight follow-up the\nPhase 5 commit flagged.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T14:24:13.019Z","updatedAt":"2026-06-25T14:24:13.019Z"},{"id":"bd9e6346-de3f-4fdf-9adf-0640aaaae265","releaseId":"a98c6c95-a694-4ba6-8732-f33e9dcab385","slug":"hrm-attendance-audit-phase-8-half-day-boundary","type":"fixed","scope":"hrm","summary":"Half-day classification at the lateness-threshold boundary is now consistent across the table, calendar, entry detail, and monthly report.","body":"Phase 8 of the HRM time-attendance audit — the final phase. The\nper-entry verdict (`computeAttendanceStatus`) used `>=` (\"at or\nabove the cutoff\") for the half-day-late branch; the monthly\nattendance kernel used `>` (\"strictly above\") for the same check.\nA worker with `effectiveLateMinutes` exactly equal to the org's\n`halfDayLateThresholdMinutes` read as `half_day` on the table /\ndetail surfaces and `late` on the calendar / report.\n\nThe kernel now uses `>=` matching the per-entry verdict, so every\nsurface agrees on the boundary case. Audit reason copy on the\nper-entry side already reads \"at/above the … cutoff\" — the kernel\nnow actually matches that copy.\n\nCloses the HRM time-attendance audit (10 of 10 phases shipped):\n- Phase 1: late visualisation unified\n- Phase 2: absent + holiday on calendar\n- Phase 3: paid vs unpaid leave distinguished\n- Phase 4: per-entry verdict uses day total for half-day\n- Phase 5: missed-shift OT suppression + multi-entry sum\n- Phase 6: half-day thresholds from org policy\n- Phase 7: break minutes + breakPaid in monthly kernel\n- Phase 8: half-day boundary operator unified  ← this commit\n- Phase 9: monthly lateness relaxation budget\n- Phase 10: per-day timezone bucketing","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-25T14:24:13.025Z","updatedAt":"2026-06-25T14:24:13.025Z"}]},{"id":"3a299d3a-c52b-4929-a049-de72cec794c3","tag":"W2026-21","slug":"w-w2026-21","version":"0.9.0","title":"W2026-21 — 68 changes this week","summary":"Auto-published weekly digest. Covers 68 changes from 2026-05-19 → 2026-05-24 merged into main.","status":"published","publishedAt":"2026-06-04T01:10:01.115Z","periodStartsAt":null,"periodEndsAt":"2026-06-04T01:10:01.115Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["auto","weekly"],"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-07-14T02:28:40.877Z","entries":[{"id":"2ea702d1-8857-443a-bbe0-7a10f20e9fc0","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-42-recurring-cadence-fix","type":"fixed","scope":"projects","summary":"Recurring tasks now fire on the correct days — \"Weekly on Friday\" starting on a Wednesday now fires on Fridays, not every Wednesday.","body":"R88.42 — Recurring-cadence calendar-sync fix. The reported case: user\npicks \"Weekly · Fri\" preset, sets Starts-at to Wed Jul 15 04:00, and the\n\"Next fires\" panel shows five consecutive **Wednesdays** — Jul 15, Jul 22,\nJul 29, Aug 5, Aug 12. The picked day-of-week is Friday, so every\nprojected fire should be a Friday. Both the preview AND the actual\ncron-fired schedule were wrong.\n\n## Root cause\n\nTwo layers of the same bug:\n\n1. **Server `nextRunAt` ignored `byweekday` and `bymonthday`.** The\n   original helper comment even admitted it: \"Phase B.4a ignores\n   byweekday / bymonthday — the cron job upgrades to handle them.\" That\n   upgrade never landed. The cron called the same helper as-is, so a\n   template's `next_run_at` advanced by `+7 days` from wherever it was,\n   never snapping to the rule's day-of-week.\n\n2. **Client preview mirrored the same shortcut.** The \"Next fires\"\n   projection in the recurring editor sheet used a client-side helper\n   built to match the server — meaning it faithfully reproduced the\n   wrong answer.\n\nAdditionally, the create/update paths seeded `nextRunAt = startsAt`\nverbatim without checking that startsAt landed on a matching weekday.\nSo the whole chain was: wrong first fire → wrong advance → all wrong.\n\n## Fix (server)\n\n- `modules/projects/src/schemas/rrule.ts` rewritten. Two helpers:\n  - `nextRunAt(rule, pivot)` — now honors byweekday for weekly and\n    bymonthday for monthly. When `interval > 1`, correctly skips whole\n    weeks/months between valid fires (biweekly Monday, quarterly 1st,\n    etc.).\n  - `firstRunOnOrAfter(rule, anchor)` — new. Returns the first date\n    on-or-after `anchor` that satisfies the rule's day-of-week /\n    day-of-month constraints. Used at CREATE / UPDATE / RESUME to snap\n    the initial `next_run_at` when the user's startsAt doesn't match.\n- `modules/projects/src/actions/recurring.ts` — `createRecurringTemplate`,\n  `updateRecurringTemplate`, and `resumeRecurringTemplate` all now use\n  `firstRunOnOrAfter` for the never-run case instead of trusting\n  `startsAt` verbatim.\n\n## Fix (client)\n\n- `apps/web/src/components/widgets/recurring-task-editor-sheet.tsx` —\n  client-side helpers rewritten as line-by-line mirrors of the server\n  ones. The \"Next fires\" preview snaps the first fire the same way the\n  server will, so the panel finally shows the truth.\n- **Snap hint** — when the picked startsAt doesn't match the rule, an\n  amber banner appears above the fires list saying \"Starts at snapped\n  forward — Wed Jul 15 → Fri Jul 17\". Users see WHY the first fire\n  moved.\n- **Weekday hint under Starts-at** — a small chip below the datetime\n  input names the picked date's weekday, turning amber when the day\n  doesn't match `byweekday` (or day-of-month doesn't match `bymonthday`\n  for monthly rules). Answers \"is my anchor compatible with my\n  schedule?\" at a glance, before save.\n\n## Test coverage\n\n10 new regression cases in `rrule.test.ts` locking in the correct\nbehavior:\n\n- Weekly `byweekday=[fri]` advances Fri → next Fri\n- Weekly `byweekday=[mon,wed,fri]` — Mon → Wed, Wed → Fri, Fri → next Mon\n- Weekly weekdays — Fri → next Mon (skips weekend)\n- Biweekly `byweekday=[mon]` — skips a whole week\n- Biweekly `byweekday=[mon,wed,fri]` — same-week Wed→Fri, Fri→2-weeks-out Mon\n- Monthly `bymonthday=[1]` — next month's 1st\n- Monthly `bymonthday=[1,15]` — same-month 1→15 hop\n- Quarterly `bymonthday=[1]` — +3 months\n- Every-other-month multi-day within-month advance\n- `firstRunOnOrAfter` — Wed → Fri snap (the reported scenario) + until-cutoff\n\nAll 47 recurring + rrule tests pass.\n\n## What was NOT changed\n\n- The RRULE schema itself is untouched — no migration needed.\n- Existing templates with `next_run_at` that never got snapped will\n  self-correct on the next cron advance (they'll advance by the CORRECT\n  cadence from their current wrong pivot, gradually landing on right\n  weekdays for weekly rules; monthly rules with wrong bymonthday\n  correct on the next month rollover).\n\n## Follow-ups queued\n\n- **R88.42-B** — \"Make recurring\" affordance on the task detail sheet\n  (open the recurring editor pre-filled from the current task shape).\n  The prefill prop on the editor sheet is already in place from R88.42-A.\n- **R88.42-C** — Add a \"Repeat\" toggle to the quick-prompt task-create\n  flow so users can commit to a recurring template from the same\n  keystroke that would have created a one-off task.\n- **R88.42-D** — Extract the shared `<RecurringConfigForm>` component so\n  both surfaces (editor sheet + task create + task detail) use one\n  block with one bug-fixed logic path.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.848Z","updatedAt":"2026-07-14T02:28:39.848Z"},{"id":"8497c0a2-a12c-4b77-9b71-63e8450270e5","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"drive-restore-sheet-files","type":"fixed","scope":"drive","summary":"Restore three drive-sheet files (commands, menubar, toolbar) that were inadvertently included as deletions in an unrelated R88.43-P2 Phase 4a commit.","body":"Revert-restore of `commands.ts` + `sheet-menubar.tsx` +\n`sheet-toolbar.tsx` under `apps/web/src/components/drive/sheet/`.\nThese three files were staged as deletions in the git index by a\nforeign session's WIP that didn't ship, and my narrow explicit-path\n`git add` + commit for R88.43-P2 Phase 4a swept them into the same\ncommit (`e25f731c`).\n\nRestored verbatim from commit `c6ed93d8` (`feat(drive): the\nspreadsheet gets our menus and toolbar, not Univer's`). Zero content\nchange vs the state before the accidental deletion.\n\n## What this fixes\n\nUsers of the native spreadsheet editor lose the custom menu bar +\ntoolbar between commits `e25f731c` and this restore. This commit\ntakes them back.\n\n## What this doesn't touch\n\nThe rest of the drive-sheet subsystem (`sheet-editor.tsx`,\n`univer-mount.ts`, `sheet-header.tsx`, `csv.ts`, imports/exports,\nthemes, etc.) is unchanged.\n\n## How this happened\n\nParallel-session index cross-contamination: a foreign session had\nuncommitted-deleted these files in the shared git index. My\n`git add path/to/task-update.ts` calls added the 5 R88.43-P2 files\non top of the already-staged deletions; `git commit` sealed the\ncombined stage.\n\nPreventive follow-up: broader use of `git status --short` before every\n`git add` (including checking for `D` prefix entries, not just `M`).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:38.718Z","updatedAt":"2026-07-14T02:28:38.718Z"},{"id":"69e8a40f-46fd-4d46-94ac-8fad0c478980","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p2-phase4bc-section-cycle-names","type":"changed","scope":"projects","summary":"Activity log now shows section + cycle name deltas — \"moved from 'To Do' → 'In progress'\" for sections, \"changed cycle 'Sprint 12' → 'Sprint 13'\" for cycles.","body":"R88.43-P2 Phase 4b + 4c — Extends the prior-state delta pattern to\nthe last two grouping fields. Completes the arc: Status / Priority /\nAssignee (P1) + Due / Start (P2) + Milestone (P3) + Parent (P4a) +\n**Section (P4b) + Cycle (P4c)**.\n\n## Handler changes\n\n**`setTaskSection`** — merged the previous two-branch shape\n(`only-fetch-self-when-attaching`) into a single always-fetch that\nreads `projectId` + `sectionId` in one query. Output carries\n`prior: { sectionId }`.\n\n**`setTaskCycle`** — added a pre-fetch that reads current `cycleId`\nbefore the cross-join check. Output carries `prior: { cycleId }`.\n\nBoth compose with the existing same-project / same-team validation\nlogic; the pre-fetch runs before those checks, so validation failure\npaths (`not_found`, `validation_failed`) return without exposing a\nprior payload.\n\n## Server enrichment (task-activity-list.ts)\n\nCollect step now also gathers section IDs (from input + `output.prior`\nof `set_section` rows) and cycle IDs (from `set_cycle` rows). Two new\nbatched lookups against `projects_project_sections` and\n`projects_cycles` resolve the IDs to names. Attached to\n`context.sectionName`, `context.priorSectionName`, `context.cycleName`,\n`context.priorCycleName`.\n\n## Client renders (task-conversation.tsx)\n\n`describeActivity` for `set_section` and `set_cycle` both use the same\nfour-tier fallback established in Phase 3 (milestone) and 4a (parent):\n\n| Verb | Case | Phrasing |\n|---|---|---|\n| `set_section` | Change | `moved from \"To Do\" → \"In progress\"` |\n| `set_section` | Clear w/ prior | `removed from section \"To Do\"` |\n| `set_section` | Link w/ new only | `moved to section \"In progress\"` |\n| `set_section` | Neither name | `moved to a section` |\n| `set_cycle` | Change | `changed cycle \"Sprint 12\" → \"Sprint 13\"` |\n| `set_cycle` | Clear w/ prior | `removed from cycle \"Sprint 12\"` |\n| `set_cycle` | Link w/ new only | `moved to cycle \"Sprint 13\"` |\n| `set_cycle` | Neither name | `moved to a cycle` |\n\nOlder audit rows without `output.prior` (or with since-deleted\nentities) gracefully degrade to earlier phrasing tiers.\n\n## Tests\n\n56/56 pass across three suites:\n\n- `task-update.test.ts` (29 tests) — `set_section` tests updated for\n  the merged pre-fetch shape; assertions for `prior.sectionId`\n- `cycle.test.ts` (23 tests) — `set_cycle` test updated with\n  `PRIOR_CYCLE` selectRow + assertion for `prior.cycleId`\n- `task-activity-list.test.ts` (4 tests) — unchanged; existing shape\n  covers the new fields via the same context null-when-empty pattern\n\n## Arc summary\n\nR88.43-P2 is now feature-complete for all core task fields:\n\n| Phase | Fields | Ship |\n|---|---|---|\n| P1 | Status / Priority / Assignee | `10e662d9` |\n| P2 | Due / Starts | `ed363c8e` |\n| P3 | Milestone (with name) | `a75d5a71` |\n| P4a | Parent (id only) | `e25f731c` |\n| **P4b + 4c** | **Section + Cycle (with names)** | this |\n\nOnly **Parent (name enrichment)** remains — resolving `parentTaskId`\nto `\"TEAM-42\"` needs a batched tasks-JOIN-teams query. Queued as a\nsmall follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.815Z","updatedAt":"2026-07-14T02:28:39.815Z"},{"id":"444d08c5-dcb1-4530-81ac-40bfb755da3c","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-40-recurring-editor-presets-preview","type":"changed","scope":"projects","summary":"Recurring task editor gains quick-preset chips (every weekday, biweekly, monthly-1st, etc.) and a \"Next fires\" preview panel showing the next 5 spawn dates before you commit.","body":"R88.40 — First phase of the recurring task revamp. The template\neditor was functional but bare — users had to hand-configure every\nfield for standard cadences and couldn't verify a schedule before\nsaving.\n\n## Quick preset chips\n\nSix one-tap presets fill freq + interval + byweekday/bymonthday in a\nsingle click:\n\n- **Every weekday** — weekly, interval 1, Mon–Fri\n- **Weekly · Mon** — weekly stand-up cadence\n- **Weekly · Fri** — Friday retro / weekly report cadence\n- **Biweekly · Mon** — sprint kickoff\n- **Monthly · 1st** — monthly bill / retro / OKR check\n- **Quarterly · 1st** — QBR cadence\n\nChip becomes \"selected\" (accent fill) when the current rule\nmatches its exact combination. Tapping a different chip snaps\nthe form to the new preset.\n\n## \"Next fires\" preview panel\n\nBelow the Starts at / Until row, a small panel now shows the next 5\nprojected spawn timestamps computed live from the current rule.\nLive-updates as freq / interval / weekday / startsAt / until change,\nso users can eyeball \"yep, Fri May 30 / Fri Jun 6 / Fri Jun 13…\"\nbefore hitting Create.\n\nThe first fire chip picks up an accent tint + a \"1st\" label + the\ntime-of-day so the \"when does this actually fire\" is unambiguous.\nWhen the Until date cuts the projection short of 5 fires, a small\nhint surfaces so users know to loosen the rule if they wanted more.\n\n## What's next\n\nR88.41 will add the \"Make recurring\" affordance to the task detail\nsheet so users can convert an existing task to a recurring\ntemplate pre-filled with its title / description / status /\npriority / assignee / due date, instead of recreating from scratch.\n\n## What was skipped\n\n- Backend `tasks.recurring_template_id` FK — deferred to a\n  future revamp phase; the current UX creates a fresh template\n  from the task shape but doesn't link the source task back.\n- Task follower / watcher revamp — queued as a separate ticket\n  after the user's mid-turn ask.\n- Permission fix for employees/managers deleting their own\n  tasks — queued as a separate contained fix.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.354Z","updatedAt":"2026-07-14T02:28:39.354Z"},{"id":"ca4d50cc-8633-4fe1-98a1-3af65a462f1e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-41-task-followers","type":"changed","scope":"projects","summary":"Task detail sheet's \"Collaborators\" row revamped into \"Followers\" — you can now add teammates directly from the sheet, avatars show the reason each person is following, and terminology is unified across the row.","body":"R88.41 — Task follower/watcher revamp on the task detail sheet.\n\n## The gap\n\nThe old CollaboratorsRow only let the actor toggle their OWN\nfollow state. There was no way to add a colleague as a follower\nfrom the task sheet — users would have to leave the flow, and\noften didn't even realize adding others was possible. On top of\nthat, the terminology was mixed: the row label said\n\"Collaborators\" but the button said \"Watch / Watching\", and the\navatars gave no hint about why each person was following.\n\n## Add-follower Picker\n\nA new \"+ Add\" pill next to the follow toggle opens a searchable\nPicker over the org's users with anyone already-following\nfiltered out. One click adds the picked user as a follower via\nthe existing `projects.task.add_watcher` action. Empties out with\na friendly \"Everyone in the org is already following\" message\nwhen the pool is exhausted.\n\nUnder the hood, `addWatcher` / `removeWatcher` mutations now take\nan optional userId (defaults to current user) so the same action\nserves both self-follow toggle and add-someone-else flows without\nduplicating the mutation shape.\n\n## Per-avatar source tooltip\n\nEach avatar's tooltip now surfaces the join reason from\n`watchers.source`:\n\n- Manual\n- Auto · assignee\n- Auto · mentioned\n- Auto · parent task\n- Auto · project member\n\nAnswers \"why is Priya on this?\" without a data-model detour.\n\n## Terminology unified\n\n- Row label: Collaborators → **Followers**\n- Button: Watch / Watching → **Follow / Following**\n- Empty state: \"No collaborators\" → **\"No followers yet — add\n  someone who should get notifications.\"**\n- New hint line (shown to non-followers when others follow):\n  **\"Followers get in-app notifications on status, assignee,\n  and comment changes.\"**\n\n## Tactile polish\n\nThe follow toggle picks up the module-wide vocabulary — hover\nscale-[1.04] + active scale-95 + focus-visible accent ring.\nConsistent with the rest of the R88.30+ inline editor polish.\n\n## What's queued\n\n- Per-avatar remove popover — clicking the × on any avatar\n  removes that specific person. The `onRemoveFollower` callback\n  is wired through the row's props for the follow-up.\n- Optional email digest tier — some users may want to follow\n  quietly (mentions only) vs actively (every change); needs a\n  schema `notification_tier` column and is queued separately.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.450Z","updatedAt":"2026-07-14T02:28:39.450Z"},{"id":"99728d7e-9648-4961-a83b-2baff2523db5","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-42-c-quick-prompt-repeat","type":"added","scope":"projects","summary":"Task quick-prompt gains a \"Make this recurring\" toggle — hit Enter to open the recurring editor with your title + project already filled in.","body":"R88.42-C — Third and final ship in the recurring-UX unification arc.\nR88.42 fixed the cadence bug; R88.42-B added \"Make recurring\" on the\ntask detail sheet; this one closes the loop from the OTHER end — the\ntask create flow.\n\n## Gap this closes\n\nBefore: users could only reach a recurring template through one of\ntwo indirect paths — dig into project settings, or create a one-off\ntask first and then click \"Make recurring\" on its detail sheet. If\nthey knew from the outset the work was recurring, they still had to\nwalk the one-off path first.\n\n## What ships\n\nNew \"Make this recurring\" toggle chip inside the task quick-prompt,\nbelow the project picker. When ON:\n\n- The primary button relabels from **Create** → **Set schedule…**\n- The footer hint switches from \"to create and edit\" → \"to set the\n  schedule\"\n- Submitting (Enter or clicking the button) opens the same recurring\n  editor sheet used by project settings + the task detail sheet, with\n  the title + project already threaded through as prefill\n\nThe user picks a cadence in the editor sheet, saves, and both surfaces\nclose cleanly. If they cancel out of the editor sheet, the quick-prompt\ncloses too (the intent was \"done with this create flow\" either way).\n\n## Design decisions\n\n- **Toggle, not radio.** A binary \"one-off vs recurring\" state — matches\n  how operators actually decide (they usually know which mode they\n  want before they start typing).\n- **Chip position.** Right below the project picker so the user sees\n  the toggle before they hit Enter. Positioning it in the footer would\n  hide the mode switch behind the \"Create\" affordance.\n- **Same sheet, not an inline embed.** The full cadence + preview\n  block is too heavy for the \"quick\" nature of the quick-prompt. The\n  pop-out sheet handles the complexity; the quick-prompt stays lean.\n- **Consistent visual language.** Same ClockClockwise glyph + same\n  scale-[1.03] hover + scale-95 press motion vocabulary as the editor\n  sheet's preset chips + the task detail sheet's \"Make recurring…\"\n  pill.\n\n## Where the recurring UX now lives\n\n| Surface | How to reach recurring |\n|---|---|\n| Project settings → Recurring templates | Existing \"New template\" button |\n| Task detail sheet | R88.42-B \"Make recurring…\" pill in Automation section |\n| Task quick-prompt | R88.42-C \"Make this recurring\" chip below project picker |\n| Board / list \"+\" quick-add | Inherits from the quick-prompt |\n\nAll three surfaces open the SAME `<RecurringTaskEditorSheet>` — one\nbug-fixed logic path across every entry point.\n\n## What still isn't unified\n\nGenuine inline embed of the schedule block IN the quick-prompt (rather\nthan opening the pop-out sheet) would need the schedule block extracted\ninto a shared `<RecurringConfigForm>` component. Deferred to R88.42-D\nif the pop-out approach ever feels heavy in practice. For now the\npop-out shape wins on quick-prompt lightness.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.594Z","updatedAt":"2026-07-14T02:28:39.594Z"},{"id":"cf24b03c-79f7-4e31-9610-b26ad74c5e31","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p2-phase4-parent-name","type":"changed","scope":"projects","summary":"Activity log now shows \"changed parent WEB-42 → WEB-58\" for parent-task changes — the last piece of the R88.43-P2 arc.","body":"R88.43-P2 Phase 4 (parent name enrichment) — Completes the arc.\nEvery core task field now surfaces \"from X → Y\" deltas in the activity\nfeed with names resolved server-side.\n\n## What ships\n\n**Server enrichment** (`task-activity-list.ts`):\n\n- Collect step gathers `parentTaskId` from `set_parent` rows' input +\n  `output.prior`\n- One batched query joins `tasks` + `projects_teams` to build\n  `${team.taskPrefix}-${task.number}` labels\n- Attached as `context.parentNumberLabel` (from input) and\n  `context.priorParentNumberLabel` (from `output.prior`)\n- Same pattern as Phase 3/4b/4c label/section/cycle enrichments — one\n  extra SELECT per activity page load\n\n**Client render** (`task-conversation.tsx`):\n\n`describeActivity` for `set_parent` upgrades from the Phase 4a\n\"acknowledge prior exists\" fallback to the same four-tier fallback\nused by milestone/section/cycle:\n\n| Case | Phrasing |\n|---|---|\n| Change (both resolved, differ) | `changed parent WEB-42 → WEB-58` |\n| Clear with named prior | `removed parent WEB-42` |\n| Clear with id-only prior (Phase 4a) | `removed the parent task` |\n| Link with new only | `set parent to WEB-58` |\n| Neither name | `set the parent task` / `cleared the parent task` |\n\nOlder audit rows without `output.prior` or with since-deleted parent\ntasks degrade gracefully through the tiers.\n\n## Design\n\n- **`taskPrefix-number` format** matches ADR 0011 D14 (the canonical\n  task number label) — same shape used across the whole product\n- **`innerJoin` on `projectTeams.id = tasks.teamId`** — every task\n  belongs to a team (immutable per Belonging-FK precedence in the\n  module CLAUDE.md), so the join is safe without a LEFT variant\n- **`isNull(projectTeams.deletedAt)`** on the join — soft-deleted\n  teams (rare) will drop the label; client falls back to the\n  id-only phrasing\n\n## R88.43-P2 arc complete\n\n| Phase | Fields | Ship |\n|---|---|---|\n| P1 | Status / Priority / Assignee | `10e662d9` |\n| P2 | Due / Starts | `ed363c8e` |\n| P3 | Milestone (with name) | `a75d5a71` |\n| P4a | Parent (id-only groundwork) | `e25f731c` |\n| P4b + 4c | Section + Cycle (with names) | `82bdc474` |\n| **P4** | **Parent (with name)** | this |\n\nEvery core task field now surfaces name-enriched \"from X → Y\" deltas\nin the activity feed. No further follow-ups queued for this arc.\n\n## Tests\n\n56/56 tests continue to pass across `task-update`, `cycle`, and\n`task-activity-list` suites. The enrichment adds server code paths\nthat would be exercised by an integration test; unit tests (which\nuse fakeDb reuses selectRows) can't distinguish the batched lookup\ncall, so coverage relies on manual + integration verification.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.652Z","updatedAt":"2026-07-14T02:28:39.652Z"},{"id":"5dee879f-a16f-44de-bdfc-d2e093b60e38","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-45-p3-meta-line-polish","type":"changed","scope":"projects","summary":"Project header's meta line gets a proper hierarchy — team, target date, client, and engagement all render as icon-led chips; target date shows \"Jul 17 · in 3d\" with overdue/soon tinting.","body":"R88.45 Phase 3 — Meta line polish under the project title. The\nprevious line mixed styles: team + task prefix was plain text, target\ndate was a raw ISO string (`target 2026-07-17`), client + engagement\nwere pill chips. Reading it required parsing four different visual\nlanguages.\n\n## Consistent chip vocabulary\n\nAll four facets now render as icon-led pills:\n\n- **Team** — `UsersThree` glyph + team name + faint task prefix\n  (`TeamName WEB`). Was plain text \"TeamName · WEB\".\n- **Target date** — new `<TargetChip>` component (see below)\n- **Client** — `Buildings` glyph + client name. Was \"Client: Acme\"\n  with \"Client\" as a plain-text label.\n- **Engagement** — `Handshake` glyph + engagement name. Same\n  simplification as client.\n\nScanning is O(icon) instead of O(word).\n\n## Target date chip\n\nThe most impactful piece of the polish. Was:\n\n    target 2026-07-17\n\nNow:\n\n    📅 Jul 17 · in 3d\n\n- **Format** — `Jul 17` short-locale (uses the operator's locale via\n  `toLocaleDateString`). Falls back to raw string if the ISO fails\n  to parse (defensive).\n- **Relative hint** — inside a two-week window either side of today,\n  the chip appends `in 3d` / `2d ago` / `today` / `tomorrow` /\n  `yesterday`. Outside that window (a launch date 6 months away)\n  the relative hint suppresses to keep the chip compact.\n- **Tinting** — overdue targets get an **amber** border + fill\n  (calls attention without alarming). Targets within the next 7\n  days get an **accent** border + fill. Anything further out uses\n  the neutral chip tone. Same three-tier signal design as due-date\n  chips elsewhere in the module.\n- **Timezone-invariant** — the ISO gets `T00:00:00Z` appended\n  before parsing so a Jul 17 target stays Jul 17 for operators in\n  Karachi + New York alike. The chip's `title` carries the raw ISO\n  for anyone who needs the exact date.\n- **A11y** — the relative hint is duplicated in a `sr-only` span so\n  screen readers announce \"Jul 17, in 3d\" as one phrase; sighted\n  users see the `·` separator.\n\n## Layout tightening\n\nGap between chips reduced from `gap-x-2 gap-y-1.5` to\n`gap-x-1.5 gap-y-1`. On narrow the row still wraps cleanly but\ntakes less vertical space when it does.\n\n## What's NOT changed\n\nThe chip labels (\"Client\", \"Engagement\" as prefix text) were\ndropped — the icons carry the label meaning now. Operators\ndouble-clicking to identify which chip is which get it from the\ntooltip (icon + name) which fires on hover; screen readers get the\nLink's accessible name from its children.\n\nIf field-blindness / operator-cognition testing surfaces this as a\nproblem, add a small `<span className=\"sr-only\">Client</span>`\ninside each chip for extra clarity. Not doing that preemptively —\nthe icons + names are already unambiguous in situ.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.074Z","updatedAt":"2026-07-14T02:28:40.074Z"},{"id":"2e289751-33dc-4406-b104-622122087182","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-47-c2-template-chip","type":"added","scope":"projects","summary":"Task quick-prompt gains a Template chip — pick a recurring template as a \"task shape\" and the modal auto-fills title, description, status, priority, and assignee from the template's defaults.","body":"R88.47 C-2 — Task template selection chip. Sixth chip added to the\nproperty row shipped in R88.47 C. Instead of setting its own value,\nthis chip picks a \"task shape\" and BROADCASTS to the other chips.\n\n## Design decision: reuse recurring templates\n\nWe already have recurring templates — rows that describe \"here's a\ntask shape I use repeatedly.\" The recurring cron fires them on a\nschedule; a task template picker fires them on demand from the create\nmodal. Same data, new use case. No new schema.\n\nIf we later grow a dedicated task-template table (never scheduled,\njust shape-only), it'll show up in this chip's picker as an\nadditional source. Same visual + interaction contract.\n\n## What ships\n\nNew Template chip (`Stack` glyph) added to the R88.47 C chip row.\nOn click: opens a searchable Picker over the current project's\nrecurring templates (via `projects.recurring.list`).\n\nOn select:\n\n- **Title** → `template.titleTemplate` (overrides current)\n- **Description** → `template.descriptionText` (expands the\n  description field if it was collapsed)\n- **Status chip** → `template.defaultStatus`\n- **Priority chip** → `template.defaultPriority`\n- **Assignee chip** → `template.defaultAssigneeUserId` (when set)\n\nOn clear (`×` on the chip):\n\n- **Only clears the Template link** — values that were populated\n  from the template stay. The operator may have tweaked them after\n  pick and shouldn't lose their edits.\n\n## Empty state\n\n- **Loading** — \"Loading templates…\"\n- **No templates in this project** — \"No task templates in this\n  project yet.\" (guides operators to create some via project settings\n  → Recurring templates)\n\n## Design decisions\n\n- **Project-scoped fetch.** Only shows templates from the current\n  project — recurring templates are project-scoped and picking one\n  from a different project would land the task in an unexpected\n  location. Simpler UX + lower cognitive load.\n- **Selection OVERRIDES.** The template represents an intent to\n  \"start from this shape\"; overriding current values is the natural\n  read. Operators tweak after.\n- **Clear only unlinks.** Prevents accidental data loss when the\n  operator edits after picking a template.\n- **Chip position — last in the row.** Templates are a\n  meta-affordance (they set OTHER chips); putting them last is\n  visually correct as the \"shortcut option\" after the granular chips.\n\n## What's NOT changed\n\n- **The rest of the chip row** — Status, Priority, Assignee, Start,\n  Due chips work identically to R88.47 C.\n- **The recurring system** — templates fire on schedule the same as\n  before; this chip just borrows the read action for on-demand use.\n\n## Queued\n\n- **Cross-project templates** — if operators start using recurring\n  templates as a general shape library, showing all-org templates\n  becomes valuable. Held pending signal; the projectId filter is\n  easy to widen.\n- **Task-shape templates as a first-class concept** — a dedicated\n  `projects_task_templates` table for shapes that never fire on a\n  schedule. When that lands, the chip picker widens to include both\n  sources.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.389Z","updatedAt":"2026-07-14T02:28:40.389Z"},{"id":"2d2e974b-be03-4e4c-a541-db988e8738a7","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-48-a2-right-click-menu","type":"added","scope":"projects","summary":"Right-click on any task row (list / board / grouped) now opens the row's kebab menu — the standard desktop expectation, delivered without a new Radix dependency.","body":"R88.48 A-2 — Native right-click support on task rows. Follows up\nR88.48 A (kebab-triggered context menu) with the desktop-standard\nalternative: right-click.\n\n## What ships\n\n- Data attribute (`data-task-row-menu-trigger`) added to\n  `TaskRowMenu`'s kebab button\n- `onContextMenu` handler added to the row's outer element in three\n  views:\n  - **List view** (`$projectId.index.tsx`)\n  - **Board view** (`board-view.tsx`, the card)\n  - **Grouped view** (`grouped-view.tsx`)\n- Handler shape: `e.preventDefault()` blocks the browser's native\n  context menu, then `querySelector('[data-task-row-menu-trigger]').click()`\n  fires the kebab, which opens the DropdownMenu Radix already provides\n\n## Design decisions\n\n- **No new Radix dependency.** Adding `@radix-ui/react-context-menu`\n  (Radix's dedicated primitive for cursor-anchored right-click menus)\n  would give us pixel-perfect cursor positioning, but at the cost of\n  a new runtime dep for one polish feature. The imperative\n  kebab-click approach delivers the \"right-click works\" expectation\n  using primitives we already have.\n- **Menu opens at the KEBAB position, not at the cursor.** Trade-off\n  vs the Radix ContextMenu primitive. Acceptable UX for a first cut —\n  operators understand the mental model (\"the menu opens at the\n  same corner every time\"). If pixel-perfect cursor positioning\n  becomes a common complaint, swap to Radix ContextMenu in a\n  follow-up.\n- **Zero API change to `TaskRowMenu`.** The data-attribute approach\n  requires only the row's outer element to opt-in — no props\n  bubbled through the component tree, no ref threading.\n- **`preventDefault()` blocks the browser's native menu**, which\n  would otherwise show generic browser actions (Back / Reload / etc.)\n  that aren't relevant on a task row.\n\n## Deliberately kept simple\n\n- **Doesn't apply to sub-tasks or nested rows.** The three primary\n  views cover 99% of task-row interactions; sub-tasks in the detail\n  sheet's Sub-tasks section already have their own row menu via a\n  different code path.\n- **Doesn't apply to Calendar or Gantt views.** Those have their own\n  row/bar vocabulary; right-click there needs its own placement\n  design and is queued as a follow-up if operators surface the gap.\n\n## Related\n\n- R88.48 A shipped the kebab menu on `873259bb`\n- R88.48 B added the group-header `+` on `8eba1332`\n- R88.48 C added the tab count pill on `3b63e284` (hotfixed on `fa30ae0c`)","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.452Z","updatedAt":"2026-07-14T02:28:40.452Z"},{"id":"1e013497-a40b-4edc-9879-50e53c493649","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-42-b-make-recurring-affordance","type":"added","scope":"projects","summary":"Task detail sheet gains a \"Make recurring\" affordance that opens the recurring template editor pre-filled from the task's title, description, priority, assignee, and status.","body":"R88.42-B — Second half of the recurring UX unification. R88.42 fixed\nthe cadence bug (weekly on Friday now fires on Fridays); this ship\nplugs the discovery hole: users could only reach the recurring\ntemplate editor by digging into project settings.\n\n## Gap this closes\n\nIf a user typed a task, worked with it for a while, and realized \"this\nshould really happen every week\" — the flow was: leave the task\nsheet, navigate to Project settings → Recurring templates → New\ntemplate → retype the title / description / assignee / priority /\nstatus all over again. That's the friction that made recurring feel\nlike a separate, ceremonial feature.\n\n## What ships\n\nA new \"Automation\" section in the task detail sheet's right rail,\nsitting between \"People\" and \"Metadata\", with a single \"Make\nrecurring…\" pill. Clicking it opens the same well-tested recurring\neditor sheet (project settings uses it too) in create mode with the\ncurrent task's shape pre-populated:\n\n- Title → `titleTemplate`\n- Description text → template description\n- Priority → template default priority\n- Status → template default status (clamped from `done`/`canceled`\n  down to `todo`, since spawned tasks always start fresh)\n- Assignee → template default assignee\n- Is milestone flag → template default milestone\n\nThe user only needs to pick a cadence + save. The source task is\nleft untouched; the recurring template is a separate, independent row\n(the backend FK linking the two is deferred per R88.42 changelog).\n\n## UI details\n\n- Pill picks up the module-wide tactile vocabulary — hover\n  scale-[1.03], active scale-95, focus-visible accent ring, hover\n  color-shift on the clock icon.\n- ClockClockwise Phosphor glyph — same icon the editor sheet uses in\n  its own preset chips, so the visual language is consistent.\n- Section divider labelled \"Automation\" — reserved namespace for the\n  upcoming \"Convert to sub-task\", \"Auto-close after N days\" and\n  similar follow-ups.\n\n## What isn't done yet (R88.42-C / D)\n\n- Task quick-prompt \"Repeat\" toggle — landing next as R88.42-C so\n  users can also start recurring from the initial task-create flow,\n  not only from an already-existing task.\n- Shared `<RecurringConfigForm>` extraction — landing as R88.42-D once\n  we know whether the quick-prompt embeds the schedule inline (needs\n  the extraction) or opens the same sheet as a pop-out (doesn't).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.613Z","updatedAt":"2026-07-14T02:28:39.613Z"},{"id":"c5bd37f0-f2ae-4026-ba1f-39c7c2e38b71","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p3-activity-label-milestone-names","type":"changed","scope":"projects","summary":"Task activity log now names the label (\"added label Bug\") and milestone (\"linked to milestone Q3 launch\") that a change references, not just \"added a label\".","body":"R88.43-P3 — Follow-up to R88.43. The activity log now resolves label\nand milestone IDs to display names server-side, so operators see\n`added label \"Bug\"` instead of a bare `added a label`.\n\n## Fix (server)\n\n`projects.task.list_activity` picks up two extra passes after the\naudit-log page is fetched:\n\n- Collect every `labelId` referenced by `add_label` / `remove_label`\n  rows. One batched `SELECT id, name FROM projects_labels WHERE org_id\n  = $1 AND id IN (…)` resolves them all in a single query.\n- Same shape for `milestoneId` from `set_milestone` rows against\n  `projects_milestones`.\n\nNames are attached as a `context: { labelName?, milestoneName? }`\nfield on each row. Deleted entities come back missing — the client\nfalls back to the generic phrasing, matching how deleted-user names\ndegrade to \"someone\".\n\n## Fix (client)\n\n`describeActivity` reads `row.context?.labelName` /\n`row.context?.milestoneName` and prefers the specific message when\npresent:\n\n- `add_label` with name → **added label \"Bug\"**\n- `remove_label` with name → **removed label \"Bug\"**\n- `set_milestone` with name → **linked to milestone \"Q3 launch\"**\n\nFallbacks unchanged for missing names.\n\n## What's not enriched yet\n\nCycles + sections + parent tasks still show generic messages\n(\"moved to a cycle\", \"set the parent task\"). Same pattern — collect\nIDs, batched lookup, attach to `context`. Queued as a follow-up if\noperator feedback surfaces the same friction. Not blocking on it now\nbecause labels + milestones together carry ~90% of the noise.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.881Z","updatedAt":"2026-07-14T02:28:39.881Z"},{"id":"55381e5c-fcb5-4435-a71a-4e6684467277","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p2-phase4a-parent-prior","type":"changed","scope":"projects","summary":"setTaskParent now captures the prior parentTaskId in output — activity feed uses it to differentiate \"removed the parent task\" (known prior) from \"cleared the parent task\" (unknown), setting up Phase 4b's name-enriched delta.","body":"R88.43-P2 Phase 4a — Groundwork for parent-task deltas in the\nactivity feed. Mirrors the Phase 3 milestone pattern:\n`output.prior.parentTaskId` flows through the audit_log verbatim; the\nclient picks it up and picks the tighter phrasing when known.\n\n## What ships\n\n**Schema** — `TaskMutationOutput.prior` gains three optional fields\ncovering all remaining grouping deltas: `sectionId`, `cycleId`,\n`parentTaskId`. Only `parentTaskId` is wired end-to-end in Phase 4a;\n`sectionId` + `cycleId` are queued for 4b/4c.\n\n**`setTaskParent` handler** — the pre-existing task self-lookup now\nalso reads `parentTaskId` in the same query (zero extra round-trips).\nThe old two-branch structure (only-fetch-self-when-attaching) merged\ninto a single always-fetch that handles both attach and detach flows.\nOutput carries `prior: { parentTaskId }`.\n\n**Client** — `describeActivity` for `set_parent` reads\n`row.output.prior.parentTaskId` and, when the input is null (a\n\"clear\" flow), picks the tighter `removed the parent task` phrasing\nwhen the prior is known vs the older `cleared the parent task` when\nit isn't. Older audit rows without `.prior` degrade gracefully.\n\n## What's queued for Phase 4b + 4c\n\n- **Parent name enrichment** — resolving `parentTaskId → \"TEAM-42\"`\n  server-side (matches the Phase 3 milestone-name pattern with a\n  batched tasks-JOIN-teams query). Enables `changed parent WEB-42 →\n  WEB-58` deltas.\n- **`setTaskSection` prior + section-name enrichment** — same\n  pattern, needs a `projects_project_sections.name` lookup.\n- **`setTaskCycle` prior + cycle-name enrichment** — same pattern,\n  needs a `projects_cycles.name` lookup.\n\n## Tests\n\n29/29 tests pass. Two existing setTaskParent tests updated for the\nnew pre-fetch shape and one asserts `result.value.prior?.parentTaskId`\ncarries the pre-detach value:\n\n- \"detaches a parent when parentTaskId is null\" now feeds\n  `{ projectId, parentTaskId: PRIOR }` selectRows and asserts\n  `prior.parentTaskId === PRIOR`\n- \"rejects self-parent\" gets minimal `{ projectId, parentTaskId: null }`\n  selectRows so the pre-fetch succeeds and the self-loop guard fires\n\n## Why ship 4a alone\n\nThe pattern is proven from Phases 1/2/3; 4a is the smallest verifiable\nincrement for parent — schema widened, one handler updated end-to-end,\none client render acknowledges the prior. Sections + cycles need\nname-lookup enrichment (their own SELECT batch in\n`task-activity-list.ts`) which is a self-contained follow-up\n(Phase 4b + 4c respectively).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.664Z","updatedAt":"2026-07-14T02:28:39.664Z"},{"id":"f6bdaf32-7efd-4469-a9b0-3ed382d957a4","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-45-topbar-responsiveness","type":"changed","scope":"projects","summary":"Project tab bar shows icons only for inactive tabs on phones, adds a scroll-fade indicator on the right edge, and the header title + status pill breathe better on narrow viewports.","body":"R88.45 (Phase 1) — Project topbar responsiveness sweep. The header +\ntab bar had grown to eight tabs and four right-cluster actions — the\nlayout was solid on desktop but crowded / overflowed silently on\nphones and narrow tablets.\n\n## Tab bar (project-tab-bar.tsx)\n\n- **Icon-only inactive tabs on narrow.** Under `sm` (~640px) the tab\n  strip hides the label on inactive tabs and shows only the icon,\n  wrapping in `sr-only` so the accessible name is intact for screen\n  readers. The active tab keeps its label so users always know which\n  surface they're on. On `sm+` labels come back for every tab.\n- **Right-edge scroll fade.** On `sm+` the strip is horizontally\n  scrollable when the tabs overflow — but the overflow was silent\n  before, users had to try scrolling to discover it. A soft gradient\n  fade on the right edge signals the affordance. Purely visual\n  (`pointer-events-none`), fades to the page background so it blends.\n- **Fallback title on all tabs.** Both `TabButton` + `TabLink` now\n  fall back to the label as their `title` attribute when no explicit\n  override is set. Pointer users see the tab name on hover in\n  icon-only mode.\n- **Scrollbar hidden.** `scrollbar-none` on the strip so the scroll\n  affordance is the fade + swipe, not a native gutter.\n\n## Header (routes/projects/$projectId.index.tsx)\n\n- **Title font scales.** Down from 20px → 18px under `sm` so a longer\n  project name doesn't force the status pill to compete for\n  horizontal room on phones. Letter-spacing + line-height unchanged\n  so the rhythm survives.\n- **Status pill max-width tightens.** From `max-w-[180px]` at all\n  breakpoints to `max-w-[120px]` under `sm` (still 180px on `sm+`).\n  Reclaims ~60px for the title on the narrowest viewports where the\n  status labels (\"Planning\", \"Active\", \"On hold\") almost never need\n  the full 180px anyway.\n\n## What's queued for Phase 2\n\n- **AI cluster consolidation** — under `sm` the \"Plan with AI\" +\n  \"Status update\" buttons still each render their full label, which\n  puts the outer cluster around ~180px wide. A dropdown menu\n  (\"AI ▾\") would compact to ~40px and keep both actions discoverable.\n  Deferred because it needs a menu import + a11y review.\n- **Apply template overflow.** Same rationale — a \"...\" overflow\n  chip on narrow could absorb Apply template + future secondary\n  actions without diluting the primary \"+ New task\" affordance.\n- **Meta line hierarchy.** The team · target · client · engagement\n  chip line currently wraps as a single row on `md+` and stacks on\n  narrow. Could compact to 3 chips + \"...\" on narrow, or move\n  client/engagement chips to a secondary row.\n\nNothing in Phase 2 blocks the Phase 1 wins from shipping — the\ncurrent state is already meaningfully better on narrow viewports\nthan what was there this morning.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.102Z","updatedAt":"2026-07-14T02:28:40.102Z"},{"id":"c12cd7a4-6663-4e82-aca9-1d23f1cb2b1e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-47-c-property-chips","type":"added","scope":"projects","summary":"Task quick-prompt gains a horizontal chip row — Status · Priority · Assignee · Start · Due — so operators who know these values at create time can set them without waiting for the detail sheet.","body":"R88.47 C — Inline property chips on the quick-prompt. Rounds out the\nPlane-inspired create-modal arc: R88.47 A shipped Create-more batch\nmode, R88.47 B shipped optional inline description, and now C ships\nthe always-available property row at the bottom.\n\n## What ships\n\nA horizontal chip row above the modal footer (below the description)\nwith five chips:\n\n| Chip | Icon | Behavior |\n|---|---|---|\n| **State** | `CircleHalf` | Picker: Backlog / Todo / In progress / In review / Done / Canceled |\n| **Priority** | `Flag` | Picker: Urgent / High / Medium / Low / None |\n| **Assignees** | `UserCircle` | Searchable Picker over org users |\n| **Start date** | `CalendarPlus` | Inline native `<Input type=\"date\">` |\n| **Due date** | `CalendarBlank` | Inline native `<Input type=\"date\">` |\n\nEach chip has two visual modes:\n\n- **Empty** — dashed border, faint fg, chip shows the property name\n  (\"State\", \"Priority\", etc.) — same \"empty slot waiting for content\"\n  affordance as R88.47 B's \"+ Add description\" chip\n- **Populated** — solid border with subtle bg tint, brighter fg, chip\n  shows the value (\"In progress\", \"Urgent\", \"Priya Kumar\",\n  \"Aug 15\") plus a small `×` clear affordance\n\n## Interaction model\n\n- **Enum chips** (State, Priority, Assignees) open a small popover\n  Picker on click. Search + keyboard nav come free from the Picker\n  primitive.\n- **Date chips** open an inline `<Input type=\"date\">` in a small\n  floating card below the pill. Native browser date picker handles\n  the calendar UI.\n- **Escape** closes any open picker/input without setting.\n- **`×` clear affordance** appears only on populated chips; clears\n  the value and returns the chip to its empty state.\n\n## Wiring\n\n`projects.task.create` already accepts `status`, `priority`,\n`assigneeUserId`, `startsAt`, `dueAt` — no server change needed.\nUndefined chip values drop out of the payload so the server applies\nits own defaults (Zod's `.default('todo')` etc.).\n\nThe `defaultStatus` prop (e.g. from a group-header `+` press that\nseeds the column's status) still applies, but chip-picked status\ntakes precedence:\n\n```\nstatus: propStatus ?? defaultStatus ?? 'todo'\n```\n\n## Behaviour with Repeat + Create-more\n\n- **Repeat mode** — chip row is hidden entirely; those properties\n  don't apply to recurring templates (the recurring editor has its\n  own configuration surface)\n- **Create-more mode** — chip values STAY across batch cycles as a\n  workflow. Operator entering 10 backlog cleanup tasks all at\n  \"In progress + High priority\" doesn't re-set chips each round.\n  Only title + description clear on Save\n\n## Design decisions worth being explicit about\n\n- **Chips are optional; the fast-path stays fast.** Type title →\n  Enter → task created. Chips are additive for operators who want\n  create-time properties.\n- **Same dashed-border vocabulary as \"+ Add description\"** — every\n  empty slot in the modal reads consistently.\n- **Icon-only when empty vs icon+value when populated** matches the\n  Plane pattern: `[Icon] State` (empty) → `[Icon] In progress`\n  (populated).\n- **Chip values persist across Create-more cycles.** Empirical batch\n  workflows usually share properties; not resetting saves 5 clicks\n  per iteration.\n- **Assignees is single-user only in the chip.** Multi-assignee still\n  lives in the detail sheet (the chip picker returns the primary\n  assignee only). Simplifies the picker for the 95% single-assignee\n  case.\n\n## What's queued (R88.47 C-2)\n\n- **Task template selection chip** — pick a saved recurring template\n  as a shape; auto-fills title / description / priority / assignee\n  from the template's defaults. Uses the existing `projects.recurring.list`\n  action; no schema change. Held separately because the chip's\n  behaviour differs (populates OTHER fields on selection, doesn't\n  just set its own value).\n- **Labels chip** — Labels aren't in `projects.task.create`'s input;\n  they'd need a follow-up `projects.task.add_label` call after\n  create. Small extra scope; deferred.\n- **Cycle chip** — needs a cycle-picker with team-scope validation.\n- **Section chip** — needs a section-picker per project.\n- **Parent-task chip** — needs a task-picker.\n\n## Follow-up\n\nR88.47 C-2 (template selection) is queued as the next companion.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.320Z","updatedAt":"2026-07-14T02:28:40.320Z"},{"id":"12976171-4441-42b1-803b-c5809d48fedd","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p2-prior-state-deltas","type":"changed","scope":"projects","summary":"Task activity log now shows \"Todo → In progress\" for status changes, \"Medium → High\" for priority, and \"Alice → Bob\" for reassignments — instead of dropping the prior value on the floor.","body":"R88.43-P2 — Prior-state deltas in the task activity feed. R88.43\nnamed the field that changed; this fills in the \"from what\" half.\n\n## Root problem\n\nThe `set_status`, `set_priority`, `set_assignee` actions read prior\nstate to include it in their **event payload** (for outbox\nsubscribers). But the audit_log — which the activity feed reads —\nonly captured action `input` and `output`, not the event. So the\nprior value existed in one place and was invisible in another. The\nfeed could only say \"moved to In progress\"; the operator wanting\n\"moved from Todo → In progress\" had no way to see it.\n\n## Fix — the schema-free path\n\nInstead of adding an `audit_log.prior` column (would require a\nmigration + backfill), pipe the prior value through the action's\n**output** — which the audit_log stores verbatim. Same information,\none existing column.\n\n## Changes\n\n**Schema** — `TaskMutationOutput` in\n`modules/projects/src/schemas/task-update.ts` gains an optional\n`prior` field:\n\n```ts\nprior: z.object({\n  status: TaskStatusEnum.optional(),\n  priority: TaskPriorityEnum.optional(),\n  assigneeUserId: z.string().uuid().nullable().optional(),\n}).optional()\n```\n\nThe field is optional throughout — existing callers ignoring it\nstay compatible; the audit log picks it up automatically.\n\n**Handlers** (`modules/projects/src/actions/task-update.ts`):\n\n- `set_status` — already read prior status for the event; now\n  also includes it in `output.prior.status`.\n- `set_priority` — NOW reads prior priority (small extra SELECT\n  before the UPDATE — the transactional gap resolves by\n  last-writer-wins), returns it in `output.prior.priority`.\n- `set_assignee` — already read prior assignee for the event; now\n  also includes it in `output.prior.assigneeUserId`.\n\nOther set_* actions (due, starts, section, cycle, milestone,\nparent) not yet enriched — their current summaries (\"set due date\nto Aug 3\", \"moved to a cycle\") are already informative enough.\nExtended later if operators surface the gap.\n\n**Client** (`apps/web/src/components/widgets/task-conversation.tsx`):\n\n- New `priorFromOutput()` helper safely unwraps `row.output.prior`.\n- Three describe-activity cases use the delta when present:\n\n  | Verb | With prior | Without prior (fallback) |\n  |---|---|---|\n  | `set_status` | `moved from Todo → In progress` | `moved to In progress` |\n  | `set_priority` | `changed priority Medium → High` | `set priority to High` |\n  | `set_assignee` (reassign) | `reassigned Alice → Bob` | `assigned Bob` |\n  | `set_assignee` (clear) | `cleared assignee (was Alice)` | `cleared the assignee` |\n\nOlder audit rows written before R88.43-P2 have no `output.prior`\nand gracefully fall back to the R88.43 phrasing.\n\n## Tests\n\n`modules/projects/src/actions/task-update.test.ts` — 29/29 pass:\n\n- `set_priority` happy path fed with a prior-priority selectRow;\n  asserts `result.value.prior?.priority === 'medium'`.\n- `set_status` asserts `result.value.prior?.status === 'todo'` on\n  the existing transition test.\n- `set_assignee` asserts `result.value.prior?.assigneeUserId ===\n  null` on the \"assign from unassigned\" test.\n\n## What's still queued\n\n- Other set_* actions extension (due, starts, section, cycle,\n  milestone, parent). Only enrich when operators surface the gap.\n- Multi-value fields (labels, watchers) — the delta shape is\n  different (add/remove pairs, not before/after). Separate design.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.828Z","updatedAt":"2026-07-14T02:28:39.828Z"},{"id":"48fdedcd-e406-4b30-b09b-4ca25dce4be2","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-44-task-delete-archive-scoped","type":"fixed","scope":"projects","summary":"Employees can now delete + archive tasks they created; managers can do the same across tasks in their team's projects. Fixes \"actor lacks permission\" on own-created tasks.","body":"R88.44 — Task delete + archive scope fix. Reported: employees and\nmanagers hit \"actor lacks permission\" when trying to delete or\narchive tasks they had created themselves.\n\n## Root cause\n\n`projects:task:delete` and `projects:task:archive` were unscoped\npermissions with no `:own` or `:team` variants. Only admins (and\nabove) held them. Employees inheriting `SELF_PROJECTS` had NO delete\nperm at all; managers inheriting `TEAM_PROJECTS` had the unscoped\narchive (broader than intended — let them archive ANY task in the\norg) but no delete perm.\n\nBoth roles routinely create tasks in their own work. When they then\ntried to delete or archive the task they'd just written, the policy\ndenied — even though every reasonable IAM model would allow you to\ntidy up your own work.\n\n## Fix\n\nFollowed the same `:any` / `:team` / `:own` scope-suffix pattern\nalready established for `projects:task:read` (and CRM contacts,\nleads, deals, etc.).\n\n**Permission catalog** (`roles.ts`):\n\n- Added `projects:task:archive:team` + `projects:task:archive:own`\n- Added `projects:task:delete:team` + `projects:task:delete:own`\n- Both bases added to the `ScopedPermissionBase` union\n- Descriptions added to `PERMISSION_DESCRIPTIONS`\n\n**Blueprints:**\n\n- `SELF_PROJECTS` (employee) — added `:own` variants of both. Row-\n  level check in the handler requires `task.creatorUserId === actor.id`.\n- `TEAM_PROJECTS` (manager) — added `:team` variants of both.\n  Row-level check requires the actor to be a member of the task's\n  team. The previous unscoped `projects:task:archive` grant was\n  removed (managers no longer archive tasks outside their team;\n  admins keep the unscoped power).\n\n**Policies** (`policies/task.ts`):\n\n- `taskArchivePolicy` and `taskDeletePolicy` now admit any of the\n  three variants. They're a coarse gate; the handler enforces the\n  row-level match.\n\n**Handlers** (`actions/task-archive.ts`):\n\n- New `fetchTaskForWrite()` helper pre-reads `teamId`,\n  `creatorUserId`, `archivedAt`, `deletedAt` before the UPDATE fires.\n- New `guardScope()` helper implements the row-level check:\n  `:any` → allow; `:team` → verify the actor is a member of the\n  task's team via `projects_team_members`; `:own` → verify\n  `task.creatorUserId === actor.id`.\n- All four handlers (archive, unarchive, delete, restore) call the\n  guard after the fetch and before the UPDATE. They also gain an\n  earlier `not_found` when the lifecycle state (archived / deleted)\n  doesn't match the verb's target — reads cleaner than \"task not\n  found or already archived\" as a single message.\n\n## Tests\n\nOriginal 13 tests updated + 5 new regression cases (18/18 pass):\n\n- `:own` grants delete when actor is the task creator ✓\n- `:own` DENIES delete when actor is NOT the task creator ✓\n- `:own` grants archive when actor is the task creator ✓\n- `:own` DENIES archive when actor is NOT the task creator ✓\n- Policy admits any variant; only denies when zero variants held ✓\n\n## Behavior change note\n\nManagers who previously held unscoped `projects:task:archive` (via\n`TEAM_PROJECTS`) now hold `:team` instead. That's a tightening —\nthey can still archive their team's tasks (the ~95% case) but no\nlonger other teams'. If any operator relied on the broader grant\nthey can grant `projects:task:archive` explicitly via a custom\nrole, but the new default matches the \"team lead\" intent baked\ninto the manager blueprint.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.888Z","updatedAt":"2026-07-14T02:28:39.888Z"},{"id":"29bb932f-cab8-4c65-be2b-1ba223e49da9","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p2-phase2-dates","type":"changed","scope":"projects","summary":"Activity log now shows date deltas — \"changed due date May 15 → Aug 3\" for due date and start date changes, plus \"cleared due date (was May 15)\" when clearing.","body":"R88.43-P2 Phase 2 — Extends the prior-state delta pattern shipped in\nPhase 1 (status / priority / assignee) to due date + start date.\nSame `output.prior` field, same audit-log-persists-verbatim path, no\nschema change.\n\n## Handlers\n\n`set_due_at` and `set_starts_at` (both in\n`modules/projects/src/actions/task-update.ts`) now read prior date\nvalues before the UPDATE and return them in `output.prior.dueAt` /\n`output.prior.startsAt`.\n\n`set_due_at` reports `startsAt` in `prior` only when the input\nactually touched `startsAt` — so single-field due-date updates don't\ncarry spurious prior-start information.\n\n## Activity log\n\n| Verb | With prior | Without prior (fallback) |\n|---|---|---|\n| `set_due_at` (change) | `changed due date May 15 → Aug 3` | `set due date to Aug 3` |\n| `set_due_at` (clear) | `cleared due date (was May 15)` | `cleared the due date` |\n| `set_starts_at` (change) | `changed start date Jul 15 → Jul 20` | `set start date to Jul 20` |\n| `set_starts_at` (clear) | `cleared start date (was Jul 15)` | `cleared the start date` |\n\n## Design consistency\n\nMatches the Phase 1 pattern exactly:\n- New handlers unchanged in output for callers that ignore\n  `.prior`\n- Client `priorFromOutput()` widened with `dueAt` + `startsAt`\n- Older audit rows without `output.prior` fall back to the R88.43\n  phrasing gracefully\n\n## Tests\n\n29/29 pass. Three existing set_due_at tests updated for the pre-fetch\nstep; assertions added for `result.value.prior?.dueAt` and\n`result.value.prior?.startsAt`.\n\n## What's still deferred\n\n- `set_section`, `set_cycle`, `set_milestone`, `set_parent` — these\n  reference entities by ID; their prior-value display needs a name\n  lookup (like the R88.43-P3 label + milestone enrichment). If\n  extended, they'd read like `changed section 'To Do' → 'In progress'`\n  or `changed parent WEB-42 → WEB-58`. Deferred as a separate\n  enrichment cycle.\n- Multi-value fields (labels, watchers) still use the add / remove\n  event pattern — no before/after delta shape needed there.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.070Z","updatedAt":"2026-07-14T02:28:40.070Z"},{"id":"e6009e3d-1e9d-4b27-8823-7a6dd5fd7689","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-46-task-properties-panel-polish","type":"changed","scope":"projects","summary":"Task detail sheet's properties panel gets a form-style redesign — clearer section grouping with icons, more prominent labels, and a subtle left-edge accent on hover so each row visibly arms as interactive.","body":"R88.46 — Task properties panel form redesign. The right-rail\nproperty list had grown to 15+ rows across 5+ sections; the section\ndividers were tiny (9.5px caption) and row labels were faint enough\nthat operators had to hover to see them clearly. Reads like a form\nnow, not a data dump.\n\n## Section dividers (`PropDivider`)\n\n- **Label prominence** bumped from 9.5px → 10.5px, weight `medium` →\n  `semibold`, color `fg-faint` → `fg-muted`. Reads at a glance.\n- **Letter-spacing** tightened from `0.08em` → `0.06em` so the\n  caption isn't over-tracked; still uppercase for the section-header\n  feel.\n- **Vertical rhythm** increased: `mt-3 mb-1` → `mt-5 mb-1.5` between\n  sections so groups breathe visibly, not just structurally.\n- **Optional leading icon** — new `icon` prop takes any Phosphor node\n  and renders at 12px duotone in the caption row. Applied per\n  section:\n  - **Effort** → `Gauge`\n  - **Custom fields** → `Database`\n  - **People** → `UsersThree`\n  - **Automation** → `ClockClockwise` (matches the \"Make recurring\"\n    pill in the same section — visual consistency)\n  - **Metadata** → `Info`\n- **Bare-divider** case (no label) unchanged; still available for\n  sub-caption separators inside a section.\n\n## Property rows (`PropRow`)\n\n- **Label** bumped from 10.5px → 11px + weight `medium` →\n  `semibold`, color `fg-faint` → `fg-muted`. Was: \"you need to\n  hover to see me\". Now: \"here I am, and I get brighter when you\n  hover\".\n- **Letter-spacing** softened `0.06em` → `0.05em` at the new size\n  so the tightness compensates.\n- **Left-edge accent hairline** — new 2px vertical strip at\n  `absolute left-0`, anchored inside the rounded rectangle. Hidden\n  at rest (`scale-y-0`), animates to full height on row hover.\n  Reads as an \"armed\" state — the row is about to accept your click.\n  Pairs with the existing bg-tint hover so the row layers up\n  visibly as you approach it.\n- **Row padding** unchanged (py-1.5) — the left-edge accent gives\n  the \"this row means something\" affordance the extra padding\n  would have supplied, without pushing the 10+ rows off-screen at\n  lg widths.\n- **Hover label color** now goes all the way to `fg-default`\n  (was `fg-muted`) so the label reads bright at the interaction\n  moment — reinforces the \"this is what you're editing\" cue.\n\n## Design decisions worth noting\n\n- **Icons in duotone weight, not fill** — the property panel is a\n  reading surface, not an action surface. Duotone reads as\n  metadata / label decoration; fill would compete with the action\n  affordances (pickers, buttons) in the value cells.\n- **Left-edge accent scales vertically, not horizontally** — a\n  horizontal fade would suggest a swipe/slide affordance the row\n  doesn't actually offer. Vertical scale reads as a flag.\n- **Row padding kept tight** — the sheet already reserves a fixed\n  320px rail at lg; loosening rows would push the metadata section\n  below the fold. The visual weight comes from the section\n  dividers' new rhythm + the label prominence, not from row\n  spacing.\n\n## What's NOT changed\n\n- Value cells (pickers, chips, inputs, avatar stacks). They were\n  already polished across R22.3 / R88.13 / R88.17 / R88.29. Not\n  touching what works.\n- The overall grid (`grid-cols-[84px_minmax(0,1fr)]`). Consistent\n  with every other property-panel surface in the app; changing it\n  here would fragment the pattern.\n- Section order + copy. That's a content decision, not a polish\n  one; if operators want a different order they can request it.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.104Z","updatedAt":"2026-07-14T02:28:40.104Z"},{"id":"526301a3-6fca-4230-9dc4-3b7be6720baa","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-changelog-status-polish","type":"changed","scope":"marketing","summary":"Polish /changelog + /status pages — atmospheric heroes, gradient release tags, link-grow feed links.","body":"- **/changelog** — hero gains primary radial back-glow. Release `tag`\n  pill now ships a primary-tinted gradient + monospace tracking so\n  each release header reads as a real version chip, not a plain\n  neutral pill. Title hover gets a smooth color transition.\n- **/status** — hero gains a radial back-glow tinted by the\n  *overallStatus* colour (green when operational, amber when\n  degraded, etc.) — the page's atmosphere shifts to the current\n  state without being alarming. RSS / JSON / Embed feed links\n  upgraded from plain underline to `.link-underline-grow text-primary`\n  with thin border-tinted divider dots in between.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-changelog-status-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"86e99dcd-0838-4213-af08-744c968d4828","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-47-create-more-and-description","type":"added","scope":"projects","summary":"Task quick-prompt gains a \"Create more\" batch mode and an optional inline description field — enter 10 tasks in a row without opening the detail sheet, or type a title + description together up-front.","body":"R88.47 A + B — Two targeted improvements to the task quick-prompt,\ninspired by patterns market-vetted platforms (Plane / Linear) ship\nfor backlog grooming.\n\n## The two gaps this closes\n\n**A. No batch creation.** Creating 10 tasks in a row meant 10\nopen-close cycles + 10 detail-sheet transitions. Real friction\nduring grooming.\n\n**B. No description at create time.** Operators often know the\ndescription when they know the title. Making them wait for the\ndetail sheet, click description, then type is two round-trips\nthey didn't ask for.\n\n## What ships\n\n**A. \"Create more\" toggle in the modal footer.**\n\n- Small pill-styled checkbox next to the keyboard hint\n- When ON: `Save` no longer closes; it clears the title,\n  refocuses the input, keeps the project sticky, and lets the\n  operator type the next task\n- When OFF: unchanged — creates the task, opens the detail sheet,\n  closes the modal (the classic Helios fast-path)\n- Primary button label switches `Create` → `Add task` in create-\n  more mode so the semantics telegraph the mode change\n- Footer hint switches `⏎ to create and edit` → `⏎ to create + keep\n  going`\n- Auto-disabled when the Repeat toggle (R88.42-C) is on — batch mode\n  doesn't apply to recurring templates\n\n**B. Optional inline description field.**\n\n- Collapsed by default behind a dashed `+ Add description` chip —\n  the fast-path stays fast for the muscle-memory case\n- Click the chip → the pill collapses and a `<Textarea>` appears\n  with the placeholder `Click to add description`\n- Plain-text only; the rich Tiptap composer stays in the detail\n  sheet where the depth (images, embeds, tables, AI) is worth\n  the weight\n- `Remove description` button under the textarea returns to the\n  collapsed state; the description resets so an accidental\n  expand can't leak text into the created task\n- Description stays expanded across Create-more batch cycles —\n  operators entering description+title pairs don't re-toggle each\n  round\n- Multi-line Enter inside the textarea inserts a newline (native\n  behavior); the modal's Enter-to-submit hook is stopPropagation'd\n  so it can't fire from inside the textarea\n\n## Design decisions worth being explicit about\n\n- **The detail-sheet-on-Save path stays default.** That's a real\n  Helios advantage — \"created + editing\" as one seamless flow —\n  and we're not losing it. Create-more is opt-in per session.\n- **Description is plain-text only in the modal.** Rich composition\n  fragments the surface; if you want images/embeds, the detail\n  sheet is one Enter away.\n- **The chip default look is dashed border** — matches the\n  affordance vocabulary of empty date fields (dashed = \"I'm a slot\n  waiting for content\").\n- **Create-more resets on modal close.** The next fresh open starts\n  in single-shot mode. Batch is opt-in per session — the more\n  conservative default.\n- **Description resets on modal close AND on Remove.** No stale\n  text leaks between sessions.\n\n## What isn't done yet (queued for R88.47 C)\n\n- **Inline property chips** (State / Priority / Assignees / Labels /\n  Due / Start / Cycle / Section / Parent). Plane surfaces these at\n  the bottom of the create modal so operators who know these values\n  up-front can set them without the create → detail-sheet detour.\n  Held for a follow-up because the picker reuse alone doubles the\n  scope, and evidence for whether operators want create-time\n  properties is weak (nobody's complained about the current shape).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.468Z","updatedAt":"2026-07-14T02:28:40.468Z"},{"id":"306c11e1-41d3-43c9-8582-a7b1b8212f86","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-activity-detail","type":"fixed","scope":"projects","summary":"Task activity log now names the field that changed — \"marked as milestone\", \"set estimate to 4h\", \"set start date to Aug 3\" — instead of a bare \"updated the task\".","body":"R88.43 — Task activity log detail. The reported case: the operator\nsaw four consecutive \"Platform Admin updated the task\" entries in the\ntimeline with zero context about what actually changed.\n\n## Root cause\n\nTwo layers of the same gap:\n\n1. **Client `describeActivity` only checked `title` + `description`.**\n   The `projects.task.update` route accepts seven fields — title,\n   description, descriptionDoc, isMilestone, estimatePoints,\n   estimateHours, estimateTshirt. Every touch to isMilestone (the ⭐\n   toggle) or an estimate change fell through to a bare\n   \"updated the task\" — those two paths dominate real edit volume.\n\n2. **Server allowlist was missing three actions.**\n   `TASK_ACTIVITY_ACTIONS` in `task-activity-list.ts` didn't include\n   `set_starts_at`, `set_section`, or `set_milestone`. Those fire\n   from the right rail but never reached the timeline — silently\n   dropped before the client even had a chance to render them.\n\n## Fix (client)\n\nRewrote the `projects.task.update` branch as a dedicated\n`describeUpdate(input, tt)` helper. Every touched field surfaces:\n\n- `title` → `renamed the task to \"New title\"`\n- `description` / `descriptionDoc` → `updated the description`\n- `isMilestone: true` → `marked as a milestone`\n- `isMilestone: false` → `unmarked as a milestone`\n- `estimateHours` → `set estimate to 4h` (or `cleared the estimate`)\n- `estimatePoints` → `set estimate to 5 pts`\n- `estimateTshirt` → `set estimate to L`\n\nWhen a single field is touched, the specific sentence renders. When\nmultiple fields are batched, we fall back to a joined list —\n`updated title, description, and estimate` — so operators still see\nwhat class of change happened.\n\nAlso enriched:\n\n- **Create entry** — includes the title when known:\n  `created the task \"Ship recurring cadence fix\"` (was bare\n  \"created the task\").\n- **Status** — verb changed from `changed status to In progress` to\n  `moved to In progress`, matching how operators describe drag-drop\n  on the board.\n- **Start date** — new `set_starts_at` case renders\n  `set start date to Aug 3` / `cleared the start date`.\n- **Section / milestone** — new cases for `set_section` and\n  `set_milestone` render `moved to a section` /\n  `linked to a milestone` etc.\n\n## Fix (server)\n\nAdded `set_starts_at`, `set_section`, `set_milestone` to\n`TASK_ACTIVITY_ACTIONS` in `task-activity-list.ts`. The client + server\nlists are now kept in sync — every action name the client renders is\none the server surfaces.\n\n## What still isn't captured\n\n- **\"From X → To Y\" prior-state deltas** (e.g. `moved from Todo → In\n  progress`). The `audit_log` schema doesn't carry a `prior` column, so\n  this needs a server-side change (either widening the input jsonb to\n  include prior values, or a new `prior` jsonb column). Queued as\n  R88.43 Phase 2.\n- **Label / watcher names by ID.** `add_label` still reads \"added a\n  label\" without saying which label; input carries `labelId` but the\n  audit-list action doesn't join labels. Queued as a small\n  enhancement — needs a label-name lookup + graceful fallback for\n  since-deleted labels.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:39.879Z","updatedAt":"2026-07-14T02:28:39.879Z"},{"id":"e34d951b-5584-453c-b52f-a2c2976f8f73","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-45-p2-ai-dropdown-apply-icon","type":"changed","scope":"projects","summary":"Project header's AI actions collapse into an \"AI ▾\" dropdown on narrow viewports; Apply template becomes icon-only. The right cluster is ~230px narrower on phones.","body":"R88.45 Phase 2 — Header right-cluster consolidation on narrow. The\nproject detail page's action cluster (Plan with AI + Status update +\nApply template + New task) took ~414px on desktop. On phones, the\nwhole cluster would wrap under the title to a full second row — often\nrequiring the operator to scroll to see their primary \"+ New task\"\nbutton.\n\n## AI cluster becomes a dropdown on `<md`\n\nThe two-button segmented AI cluster stays on `md+` where the horizontal\nroom justifies keeping both actions surfaced. Under `md` (~768px) they\ncollapse into a single \"AI ▾\" dropdown trigger:\n\n- Same border-color + text tint as the segmented version so the AI\n  affordance still reads as a distinct control group\n- Odexy brandmark spins on hover (via `ui-ai-icon-spin-on-hover`)\n- Both menu items render the AiIcon so the dropdown items read as AI\n  actions in the menu list\n\nSaves ~140px vs the segmented cluster.\n\n## Apply template becomes icon-only on `<md`\n\n- New `Stack` Phosphor glyph — reads \"layered structure\" which fits\n  \"apply a preset structure of sections + tasks\"\n- Label stays as `sr-only` so screen readers still hear \"Apply\n  template\" + tooltip still fires on pointer hover\n- Full label returns on `md+`\n\nSaves ~90px on narrow.\n\n## Combined narrow-viewport savings\n\n| Before | After |\n|---|---|\n| AI cluster segmented (~180px) | AI ▾ dropdown (~65px) |\n| Apply template full (~120px) | Apply template icon (~40px) |\n| **Total ~300px** | **~105px** |\n\nThat's roughly 195px reclaimed for the title area on phones — a\n21-inch iPhone-15-Pro at 393px viewport now easily fits the whole\nright cluster on the same row as the title-truncation start.\n\n## What's still queued\n\n- **Meta line hierarchy** — team · target · client · engagement chips\n  currently wrap as a single row and stack on narrow. Could compact\n  to 3 chips + overflow \"...\" for a cleaner mobile layout. Deferred\n  because the current wrap is already legible; can revisit if\n  operator feedback surfaces it.\n- **Header ordering** — the right cluster still renders in DOM order\n  `AI dropdown → Apply → + New task`. On narrow you'd typically want\n  the primary \"+ New\" affordance first (leftmost after title) for\n  thumb-reach on mobile. Considered but held for a broader\n  navigation review.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.055Z","updatedAt":"2026-07-14T02:28:40.055Z"},{"id":"643e0a34-0d9e-4144-8825-8ac4a9c4a9c2","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-48-b-group-header-plus","type":"added","scope":"projects","summary":"Grouped view's group headers gain a \"+\" affordance that opens the create modal pre-seeded with the group's axis-value — combined with R88.47 A's Create-more mode, backlog grooming inside a specific bucket becomes one-click.","body":"R88.48 B — Group-header add affordance. Inspired by Plane's per-\ngroup `+` glyph (visible on every column header + group header). Ours\npairs with R88.47 A's \"Create more\" batch toggle — one click to open,\ntype 10 titles, done.\n\n## What ships\n\nNew `+` glyph in every group header of the grouped view. Positioned\nafter the collapse toggle (which owns the full row for the caret + icon\n+ label + count + progress), so the `+` sits at the far right anchor\nof the header row.\n\nClick behavior:\n- Opens the standard quick-prompt modal\n- Pre-seeds the modal with the group's axis-value when the axis is\n  supported by the quick-prompt (currently: **status only**)\n- For other axes (priority, assignee, cycle, milestone, label,\n  section) the modal opens with no pre-seed — the operator sets the\n  property later in the detail sheet\n\n## Board view already had this\n\nThe board's column header already carries a `+` (pre-existing via\n`onCreate` prop wired into the header + into a per-column\n`renderInlineAdd` slot). No change needed there — R88.48 B just brings\ngrouped view up to the same UX.\n\n## List view intentionally skipped\n\nThe list view is always ungrouped (grouping switches the operator to\ngrouped view instead). No group headers to attach `+` to.\n\n## Design decisions\n\n- **`+` OUTSIDE the collapse `<button>`**, sibling to it. Nesting a\n  button inside a button is an HTML nested-interactive violation; the\n  existing header wraps everything in a click-to-toggle button, so\n  the new `+` had to sit at the outer flex row level.\n- **`stopPropagation` on click** so the parent header's toggle\n  handler doesn't fire when the `+` is clicked.\n- **Icon-only, hover-brightens with scale-[1.06]** — matches the\n  module-wide tactile vocabulary (module row hovers, tab hovers,\n  etc.) so the glyph reads as \"clickable\" without a label.\n- **`aria-label` + `title` name the bucket** — screen readers hear\n  \"Add task to In progress\" and pointer users get the same on hover.\n- **Only status axis pre-seeds today.** Extending the quick-prompt\n  with `defaultPriority` / `defaultAssigneeUserId` /\n  `defaultCycleId` / `defaultMilestoneId` / `defaultSectionId` /\n  `defaultLabelIds` is queued (R88.47 C). The current fallback\n  (open modal, operator sets it in the detail sheet after) is\n  strictly non-worse than the alternative of showing no `+` at all\n  for those axes.\n\n## Combined effect with R88.47 A\n\nThe killer combo is now:\n\n1. Click `+` on the group header\n2. Type task title\n3. Toggle \"Create more\" (R88.47 A)\n4. Enter → task created, modal stays open with title cleared\n5. Type next title → Enter → next task\n6. Repeat until backlog groomed\n7. Escape or Cancel to close\n\nEach iteration is one keystroke + one Enter. Batch backlog grooming\nnow costs ~1 second per task instead of the ~5-second open/close\ncycle the classic flow required.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.524Z","updatedAt":"2026-07-14T02:28:40.524Z"},{"id":"8c8f0c73-701f-48c5-b9fb-012e8debc8b0","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-polish-round-7-prose-sidebar","type":"changed","scope":"chat","summary":"Message prose typography overhaul + sidebar hierarchy — chat reads as a polished publication and the sidebar as a real nav.","body":"V2 follow-up polish round 7. Focuses on the two highest-frequency\nsurfaces: the rendered message body and the sidebar chrome.\n\nWhat changed:\n\n**Message body (`.helios-message-prose`):**\n\n- Heading scale widens against the 15 px body so h1/h2/h3 read as\n  distinct levels: 20 / 18 / 16 px (was 18 / 17 / 16), with\n  `letter-spacing: -0.01em` on h1/h2 for editorial tightness.\n- Code blocks bump from 12.5 → 13.5 px and gain a subtle module-chat\n  tinted background, 1 px inset top highlight, and a module-chat-\n  tinted border so they read as \"designed code blocks\", not raw\n  monospace.\n- Blockquotes get a wider 4 px accent bar at 75% opacity (was 3 px\n  full), bumped padding, and a softer rounded right edge.\n- Anchor decoration switches to a 1 px underline at 4 px offset\n  with `text-decoration-skip-ink: auto` — an editorial / \"designed\n  link\" feel. Hover bumps to 2 px full-opacity ink.\n\n**MessageItem author block:**\n\n- Author name + AI badge migrate to the V2 type tokens (`--text-\n  chat-label` = 14 px). AI badge becomes a rounded-full pill with\n  `0.06em` uppercase tracking (matches the design-system caption\n  scale).\n- Timestamp uses `--text-chat-meta` (12 px) so it sits with the\n  other meta-level glyphs.\n- Pinned + Resolved chips switch to rounded-full pills with\n  uppercase 9.5 px label + tinted background, matching the new\n  AI-badge language. Reads as a consistent \"row metadata\" cluster.\n\n**Sidebar top band:**\n\n- \"Channels\" h2 promoted to `--text-chat-section` (14 px), with a\n  hairline divider below at 5%-of-foreground so the\n  Pulse/Saved/Scheduled/Followups button row reads as a distinct\n  chrome zone, not just floating affordances.\n\n**Sidebar active-channel state:**\n\n- Active row background gets a 12% module-chat mix into the\n  selected bg so the selection signal compounds with the existing\n  left-strip indicator.\n- Active rows gain `font-weight: 600` (read = 400; unread = 700;\n  active = 600 — three distinct strengths now).\n- Active rows pick up a 1 px module-chat ring + a soft 2 px module-\n  chat-tinted shadow so the selected row visibly lifts off the\n  sidebar.\n- The active-strip itself swaps to a vertical gradient (light hue\n  at top/bottom, full saturation at middle) so it reads as a\n  polished pane indicator, not a flat bar.\n\n**Sidebar category headers:**\n\n- Tracking widens 0.10em → 0.12em with a -0.5 px font-size tune\n  (11 → 10.5 px) so the uppercase label reads more typographically\n  intentional.\n- Category-count badge: tabular numeral 9.5 px with a 0-tracking\n  override on a tinted neutral bg (was bg-emphasis solid), so it\n  reads as supporting metadata, not as a competing pill.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck clean;\n@helios/web typecheck zero chat-related errors.\n\nThis closes the high-leverage main-surface polish work. Future\nrounds can target less-frequently-seen surfaces (poll cards,\nattachment chips, voice player) or move to behavior (Cmd+K command\npalette, sidebar Cmd+J/K nav, voice recorder).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-polish-round-7-prose-sidebar.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"7425e6ac-78c2-417b-8104-df70dfb97467","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-polish-round-8-toolbar-pills","type":"changed","scope":"chat","summary":"Hover toolbar rises with a soft spring, reaction + thread + attachment pills land on V2 tokens.","body":"V2 follow-up polish round 8. Where round 7 targeted the message\nbody + sidebar, this round targets the *interactive metadata* that\nsits around every message: the hover action toolbar, the reaction\nchip, the thread-preview pill, the URL-preview card, and the\nattachment + voice-note chips.\n\nWhat changed:\n\n**Hover message toolbar (`helios-msg-actions`):**\n\n- Entrance keyframe upgraded to a soft spring: 200 ms cubic-bezier\n  `(0.34, 1.32, 0.64, 1)` with `transform-origin: top right` so the\n  toolbar rises out of the top-right edge of the row, with a\n  scale-in from 96% → 100%. Reads as a small floating panel\n  rising into place, not a flat fade.\n- Toolbar chassis swaps to `rounded-full` with 92% bg + 14 px blur\n  + 150% saturation glass. Border softens to a 8%-of-foreground\n  color-mix so it doesn't outline-clip the pill. Outer shadow\n  layered (28 px ambient + 8 px contact + 1 px ring) so the\n  toolbar visibly hovers above the row.\n- Quick-react emoji buttons + every action button switch to\n  `rounded-full` to match the pill chassis, with `hover:scale-[1.22]`\n  on the emojis (was 1.18) so the quick-react feels lively.\n- Hover transition picks up `color` so the muted-fg icons brighten\n  to fg-default on hover.\n- Inner separator hairline swaps to a 10%-of-foreground color-mix\n  with `mx-1` (was `mx-0.5`) for better breathing room.\n\n**Reaction chip:**\n\n- Sizing tuned for the V2 scale: 14 px emoji glyph (was 13) +\n  11.5 px count, both anchored to `--text-chat-meta` (12 px) for\n  the chip body. Vertical padding bumped to 3 px so the chip sits\n  one step taller and clears the message baseline.\n- \"Mine\" state: bg goes to 14% module-chat mix into bg-default\n  (less saturated than round 7's 16%); ring softens to 1.5 px\n  22%-of-tint shadow so own-reactions read as warmly highlighted\n  without burning into the row.\n\n**Thread-preview pill:**\n\n- Migrated off the 11.5 px inline literal → `--text-chat-meta`\n  (12 px). Padding bumped 0.5 px / 3 px.\n- Participant avatars go 16 → 18 px so faces are actually\n  recognizable in the pill.\n- Inline icons (CheckCircle / ChatTeardrop) bump 11 → 12 px.\n- \"Last reply • 4m\" relative-time chip on `11px` for clear\n  hierarchy under the primary count.\n\n**URL preview card:**\n\n- Migrated three text levels to V2 tokens: site name on\n  `--text-chat-caption` (11 px) with 0.10em tracking, title on\n  `--text-chat-label` (14 px) tracking-tight, description on\n  `--text-chat-meta` (12 px) with 1.5 line-height.\n- Thumbnail track widened 96 → 104 px (more like a chip preview,\n  less like an icon).\n- Hover-only \"open\" affordance: chip grows 20 → 24 px with a\n  heavier 2 px shadow so it reads as a clickable destination\n  rather than a decorative tag.\n- Padding bumped to `px-4 py-3` so the card feels like a designed\n  surface, not a tight chip.\n\n**Attachment FileChip:**\n\n- Icon medallion bumps 36 → 40 px with an inset 1 px ring at\n  22%-tint so the type-colored block feels like a designed\n  badge.\n- Filename promoted to `--text-chat-label` (14 px) semibold\n  tracking-tight; type label + size on `--text-chat-meta` (12 px),\n  with type label as a separate 10.5 px uppercase 0.06em-tracking\n  caption so the hierarchy reads at a glance.\n- Hover state picks up a tint-aware border (28% module-chat mix\n  into border-default) and an arrow tint that turns module-chat\n  on hover.\n- Card padding bumped to `gap-3 px-3 py-2.5` for a more designed\n  feel.\n\n**Voice note metadata line:**\n\n- Migrated to `--text-chat-meta` (12 px) with the leading icon at\n  12 px (was 11) and font-weight 500 → 600 on the label so \"Voice\n  note\" reads as the row's primary glyph. Time counter switches\n  to 11 px monospace tabular nums for compactness.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero chat-related errors.\n\nThis closes the highest-leverage interactive-metadata polish.\nWhat's left is the rare-surface chrome (poll cards, image\nlightbox, channel-engagement popover, file-list popover) and the\nbehavior-side work (Cmd+K command palette, sidebar J/K nav, voice\nrecorder visual refresh).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-polish-round-8-toolbar-pills.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"9fd8fd86-1a50-421c-99d8-7a3bef0e61c7","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-polish-round-9-cards-chips-motion","type":"changed","scope":"chat","summary":"Poll card, smart-reply + entity chips, typing indicator, and jump-to-latest pill all land on V2 tokens.","body":"V2 follow-up polish round 9. Round 7 = body + sidebar. Round 8 =\nmessage metadata. Round 9 = the rich-content + transient affordances\nthat surround the main flow: in-message cards, chip families, and\nthe motion-driven indicators.\n\nWhat changed:\n\n**Poll card:**\n\n- Outer container padding bumps `px-3 py-2.5` → `px-3.5 py-3`;\n  shadow layered (22 px ambient + 1 px contact) for a designed-card\n  feel.\n- Header medallion 20 → 24 px with an inset 1 px tint ring; question\n  text on `--text-chat-label` (14 px, tracking-tight, line-height\n  1.35); meta line on `--text-chat-caption` (11 px, 0.08em tracking).\n- Option row: padding `px-2 py-1.5` → `px-2.5 py-2`; gap 1 → 1.5\n  vertical between rows; picked-state shadow softens to 1.5 px\n  22% ring (was 1 px 24%).\n- Checkbox medallion 16 → 18 px with a 50%-tint colored shadow on\n  the picked state for tactile feedback.\n- Option label promoted to `--text-chat-body` (15 px) tracking-tight,\n  weight 500 (unpicked) / 600 (picked).\n- Percent counter goes module-chat-tinted when the actor picked\n  that option; vote-count on `--text-chat-meta`.\n- Voter overflow chip (+N more) becomes a tinted uppercase 10 px /\n  0.04em tracking caption instead of a bold neutral pill.\n- Footer gains a 5%-fg hairline divider + 8 px top padding so the\n  \"12 voters\" summary reads as a designed footer, not a floating\n  line; close/reopen button switches to `rounded-full` pill at\n  11.5 px semibold.\n\n**Quick-reply chips (AI smart replies):**\n\n- Loading state on `--text-chat-meta` (12 px); icon 11 → 12 px.\n- \"Suggested\" header chip on `--text-chat-caption` (11 px / 0.08em\n  tracking) with 12% AI-tinted bg.\n- Tone chips: vertical padding `py-0.5` → `py-[3px]`; gap 1.5 → 2;\n  active scale 0.98 → 0.97; hover shadow lifts to 18 px / -6 y.\n- Tone medallion 20 px with an inset 1 px tint ring; icon 10 → 11 px;\n  scale-on-hover 1.08 → 1.10.\n- Label switches to semibold tracking-tight.\n\n**Entity-link chip (recognised Helios URLs in message body):**\n\n- Migrated 11.5 px literal → `--text-chat-meta`.\n- Tone medallion goes 14 → 16 px square with an inset tint ring;\n  icon 9 → 10 px.\n- Hover border darkens 50% → 55%; hover shadow lifts 4 px to 6 px\n  ambient with a 38% tint glow.\n- Live-status pill keeps its 9.5 px scale (already on the V2\n  caption scale).\n- Trailing arrow 10 → 11 px so the open-in-new affordance reads at\n  glance.\n\n**Typing indicator:**\n\n- Container migrated to `--text-chat-meta`; height 20 → 24 px so\n  the dots have room to breathe under the composer.\n- Avatar 14 → 18 px so the typer's face is recognisable.\n- Pill padding `px-2 py-0.5` → `px-2.5 py-[3px]`; label promoted\n  from 10.5 px medium → 11.5 px semibold tracking-tight.\n\n**Jump-to-latest pill:**\n\n- Migrated 12 px literal → `--text-chat-meta`; chassis padding\n  `px-3.5 py-1.5` → `px-4 py-2` so it reads as a primary\n  affordance.\n- Backdrop blur 10 → 14 px with 150% saturation; idle bg goes 92%\n  → 90% so the glass effect reads more clearly; border softens to\n  a 10%-fg color-mix (was border-strong).\n- Hover shadow upgraded 24 px / -10 y → 30 px / -10 y with a\n  larger ambient drop.\n- Unseen-count badge: 18 → 20 px diameter with an inset 1 px\n  highlight on the white-on-tint pill, font 10 → 10.5 px.\n- Down-arrow glyph promoted to 13 px so it reads as a clear\n  direction signal.\n\nVerification: chat 107/107 tests pass; @helios/chat typecheck\nclean; @helios/web typecheck zero round-9-file errors.\n\nThis completes the round of rich-content + chip + motion polish.\nRemaining lower-leverage targets: image lightbox, channel-files /\nchannel-engagement / pinned / saved popovers, voice-recorder UX,\nand behavioral work (Cmd+K command palette, sidebar J/K nav).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-polish-round-9-cards-chips-motion.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"99224468-2afa-4767-ae14-67328a4f782e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-v2-bloom-and-own-dm-strip","type":"changed","scope":"chat","summary":"Ask Helios opens with a one-shot Sparkle bloom; own messages in DMs get a module-chat tonal left strip.","body":"V2 follow-up. Closes the two items the V2 acceptance test\n([UI_REVAMP_V2_SHIPPED.md](docs/chat/UI_REVAMP_V2_SHIPPED.md))\nflagged as deferred — both pure visual polish, no backend.\n\nWhat changed:\n\n- **Ask Helios bloom**: when the `<AiThreadPane>` mounts (the user\n  just clicked \"Ask Helios\"), the header Sparkle does a 720 ms\n  one-shot bloom — scales 1 → 1.4 → 1 with an AI-purple drop-shadow\n  halo that fades in and out. After the bloom finishes, the ambient\n  `helios-chat-ai-sparkle` breath takes over. New keyframe\n  `helios-chat-ai-bloom` lives in `styles.css` with a\n  `prefers-reduced-motion` fallback.\n- **Own-DM tonal left strip**: own messages in DMs and group DMs now\n  get a 3 px `--color-module-chat` 70% tonal strip on the left edge\n  + a faint 5% same-tint wash so the actor's voice reads as distinct\n  without changing the layout. Skipped in team channels (public /\n  private / announcement / external) where the tinting would be\n  visual noise in busy rooms.\n- The own-DM treatment threads `channelType` through `<MessageList>`\n  → `<MessageItem>` as a prop (typed as the existing channel-type\n  enum). When `channelType` is null (e.g. thread-pane mounts that\n  don't have a channel context), no tinting.\n\nV2 acceptance test now reads 8/8 instead of 7.5/8.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-v2-bloom-and-own-dm-strip.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"f9756efc-01d3-4c48-b55a-ca27855b9865","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-v2-channel-header","type":"changed","scope":"chat","summary":"Channel header taller + larger title — chat screen feels like an app window, not a toolbar.","body":"V2 Phase 2 of 6. Refines the channel header chrome to match the\ntypography + glass language Phase 1 introduced.\n\nWhat changed:\n\n- Channel header height bumped from 56 px → 64 px via the new\n  `--space-chat-header` variable, giving the larger title (17 px,\n  was 15.5 px) the breathing room it needs.\n- Header background moved from 88% bg-default to 92% bg-default with\n  12 px blur + 140% saturation glass — matches the popover language\n  introduced in earlier polish rounds.\n- A subtle vertical divider now sits between the title block and\n  the right-side action cluster on md+ viewports so the buttons\n  (Threads / Pinned / Files / Decisions / Snapshot / DensityToggle\n  / Ask AI / ChannelAdminMenu) read as a discrete group.\n- DM subtitle bumped from 11 px → 11 px with the V2 caption token\n  (consistent letter-spacing) and the more legible 0.08em tracking.\n- Header bottom-border moved from a tinted `--border-subtle` to a\n  true 6%-of-foreground hairline so the line reads as a chrome\n  separator, not a colored tint.\n- `<ThreadPane>` and `<AiThreadPane>` headers stay at 48 px\n  (subordinate surfaces) but pick up the V2 section-token\n  typography for visual consistency with the main header.\n\nReference: `docs/chat/UI_REVAMP_V2_PLAN.md` Phase 2.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-v2-channel-header.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"3b77ae17-bb7a-48aa-844c-ad646d5d7fe1","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-developers-partners-integrations-polish","type":"changed","scope":"marketing","summary":"Polish /developers /partners /integrations heroes + grids with staggered reveal.","body":"Three more standalone-page sweeps:\n\n- **/developers** — hero gains primary radial back-glow + dotted-grid\n  texture matching `/ai`. CTAs upgraded to `cta-lift` +\n  `data-magnetic`. The \"Six concepts\" architecture grid wraps in\n  `.reveal` + `<FeatureGrid stagger>` so the 6 concept tiles cascade in.\n- **/partners** — hero gains primary radial atmosphere. CTAs upgraded\n  to `cta-lift` + `data-magnetic`. \"What you get\" benefit grid +\n  \"How it works\" 3-step grid + \"Two ways to earn\" pricing grid all\n  wrap in `.reveal` + `.reveal-stagger`. Step cards upgraded from\n  plain border to `card-lift` with primary-tinted hover shadow + a\n  primary top-strip that opacity-fades to 100% on hover. Reseller-\n  margin pricing card gains the \"★ Most partners\" floating pill above\n  its header (same idiom as the home CompetitiveMatrix).\n- **/integrations** — hero gains primary radial atmosphere. Each of\n  the 6 category sections wraps in `.reveal` with\n  `<FeatureGrid stagger>` so the integration tiles cascade in as the\n  reader scrolls past each category.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-developers-partners-integrations-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"4016bea2-d3da-4f3a-9455-cf2c11892987","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-v2-message-composer","type":"changed","scope":"chat","summary":"Message body 14→15 px, line-height 1.6, larger author avatars, taller composer. Chat reads at native scale.","body":"V2 Phase 3 of 6. Bumps the message column + composer to a native-feeling\nscale — the most-used surfaces in the chat module.\n\nWhat changed:\n\n- **Message body** (`.helios-message-prose`) bumped from 14 px to 15 px\n  with a 1.6 line-height. This is the standard 2026 web-chat scale\n  (Slack, Beeper) and the single biggest perceived-quality shift.\n- **Author avatars** in the message column bumped from `size=\"sm\"`\n  (24 px) → `size=\"md\"` (32 px). Faces become legible at arm's length.\n  Avatar gutter widens from `w-9` (36 px) → `w-10` (40 px) to seat the\n  larger circle with 4 px breathing room.\n- **Message row gap** for new-author rows bumped from `py-1` (4 px) →\n  `py-1.5` (6 px). Continuation rows keep `py-px` so streaks of\n  messages still read as one block.\n- **Composer** first-row min-height bumped from 40 px to 44 px via the\n  new `--space-chat-composer-min` variable. Editor font-size now uses\n  `--text-chat-body` (15 px) so the typing experience matches the\n  rendered message scale.\n- **Cozy density** message body bumped from 15.5 px → 16 px for\n  arm's-length reading on larger displays.\n\nReference: `docs/chat/UI_REVAMP_V2_PLAN.md` Phase 3.\n\nDeferred to Phase 3.5 or a follow-up: own-DM tonal left strip\n(requires threading channel type through `<MessageList>` → `<MessageItem>`).\n\nPhases 4-6 follow: window-inset + glass refinements (Phase 4),\ncommit microinteractions (Phase 5), final density tune (Phase 6).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-v2-message-composer.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"9a611d8f-e303-4325-b8fe-26f88f0be719","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-v2-spring-motion","type":"changed","scope":"chat","summary":"Spring entrance on optimistic sends + reaction chips + channel-switch crossfade. Chat feels rewarded, not utilitarian.","body":"V2 Phase 5 of 6. Adds commit-affirmation micro-motion to the moments\nwhere the user wants the app to celebrate their action: send a message,\nadd a reaction, switch channels.\n\nWhat changed:\n\n- **New `helios-chat-spring-in` keyframe** — 320 ms scale-from-85%\n  with a soft 4% overshoot at 60% before settling. Curve\n  `cubic-bezier(0.34, 1.56, 0.64, 1)` — the standard \"natural spring\"\n  shape used by Linear / Cron / Things.\n- **Optimistic message rows** mount with the spring so the user\n  visibly feels their send commit. Once the canonical row swaps in,\n  no more animation (single one-shot).\n- **Reaction chips** mount with the spring when newly added so the\n  reaction \"pops in\" as user feedback.\n- **New `helios-chat-column-fade-in` keyframe** — 180 ms fade with\n  a 2 px slide-up. Applied to the message-list scroll container,\n  which is keyed by channelId. Each channel switch now crossfades\n  the new content in instead of showing a blank frame while the\n  virtualizer measures + paints.\n- **Reduced-motion guards** collapse both new keyframes to an 80 ms\n  fadeIn so vestibular-sensitive users still get smooth state\n  changes without the spring.\n\nScope is deliberately narrow. The spring is NOT applied to typing\nindicators, read receipts, badge counts, or navigation transitions —\nthose need crisp instant state changes.\n\nReference: `docs/chat/UI_REVAMP_V2_PLAN.md` Phase 5.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-v2-spring-motion.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"f86d0a68-5744-4f25-b39f-7f52bf6e5156","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-v2-typography-sidebar","type":"changed","scope":"chat","summary":"Larger typography, taller sidebar rows, bigger DM avatars — chat reads as a premium app, not a dashboard.","body":"V2 polish round (Phase 1 of 6). Codifies the chat-surface type scale as\nCSS variables and re-bases the sidebar to match leading 2026 chat apps.\n\nWhat changed:\n\n- **Sidebar channel/DM rows** are now 36 px tall (was 32 px), with 14 px\n  text (was 13.5 px). DM avatars bumped from 16 px to 24 px so faces\n  are recognisable at arm's length. Channel-icon medallions get a 24 px\n  tinted square so the sidebar's left column reads as a consistent\n  grid.\n- **Catch-up inbox** at `/chat/` got a 64 px header (was 56 px) with a\n  22 px title (was 15.5 px). Icon medallion grew to 36 px with a soft\n  module-chat ring + shadow. The \"You're all caught up\" empty-state\n  title also bumped to 24 px.\n- **New CSS variables** under `[data-helios-chat]` codify 8 type tokens\n  (display / title / section / body / row / label / meta / caption /\n  mono) and 5 space tokens (row / header / composer-min / toolbar /\n  divider) so every chat surface references one source of truth.\n- **Density toggle re-base**: Compact mode now matches the pre-V2 scale\n  (13.5 px sidebar, 14 px body, 32 px rows) for users on dense\n  workstations; the new \"Default\" is V2's scale; Cozy bumps further.\n\nReference: `docs/chat/UI_REVAMP_V2_PLAN.md` Phase 1.\n\nPhases 2-6 land in follow-up commits: channel header (Phase 2),\nmessage row + composer (Phase 3), window-inset + glass refinements\n(Phase 4), commit microinteractions (Phase 5), final density tune\n(Phase 6).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-v2-typography-sidebar.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"c04b82f8-7272-45f9-bfc5-fbbac8161108","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"chat-v2-window-chrome","type":"changed","scope":"chat","summary":"Rounded inner top corner + hairline window divider — chat surface reads as an app pane, not a CSS box.","body":"V2 Phase 4 of 6. Subtle window-frame chrome so the chat surface\nvisibly separates from the sidebar + browser chrome on desktop.\n\nWhat changed:\n\n- The chat layout root (`<ChatLayout>`) now rounds its top-left\n  corner with `--radius-chat-inner` (12 px) on lg+ viewports, so the\n  message column reads as a separate window pane next to the\n  sidebar. On `<lg`, the corner stays square so narrow viewports\n  don't waste horizontal pixels.\n- A subtle 1 px hairline on the left edge (`inset 1px 0 0 0` shadow\n  at 6% foreground) reads as a chrome divider with the sidebar, not\n  a generic CSS border.\n\nThe change is invisible on mobile + on collapsed-sidebar narrow\ndesktop layouts, but on full-width 1280-px-and-up it makes the\nchat surface visibly an app pane rather than a content box.\n\nReference: `docs/chat/UI_REVAMP_V2_PLAN.md` Phase 4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-19-chat-v2-window-chrome.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"6f571650-6500-402c-a218-24cc4309030a","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-bento-livestats","type":"changed","scope":"marketing","summary":"Add a live-stat pill to each ModuleBento tile so every tile feels alive.","body":"Each tile in the §4 ModuleBento now carries a small accent-tinted pill\nto the right of the module name with a \"right now\" snapshot — `124\nopen · $1.4M pipeline`, `4 cycles · 86 tasks`, `1,204 sent · 98.6%\ndelivered · 30d`, etc. A small pulsing dot in the accent colour sits\non the left of each pill.\n\nThe tiles read as alive even before a real screenshot lands, and each\npill carries a different shape — counts + currencies + percentages —\nso the grid doesn't look like 14 identical stat strips.\n\nThe existing Beta pill (Inventory only) takes priority when present;\nliveStat shows on every other tile.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-bento-livestats.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"e5545065-cfa9-40e4-af74-68d18704a87c","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-compare-page-redesign","type":"changed","scope":"marketing","summary":"Compare-pages — brand face-off hero + designed honest-list columns + paired pricing cards.","body":"The 6 vendor-comparison pages (HubSpot / ClickUp / Notion / Monday /\nOdoo / Zoho One) share `<ComparePageTemplate>`. Three section\nredesigns:\n\n- **Hero face-off** — instead of just an \"X vs Y\" headline, the hero\n  now shows `<HeliosMark>` and the competitor's real brand glyph on\n  either side of a small primary \"vs\" pill (face-off composition).\n  Glyphs sit in rounded square wells with module-style shadows. The\n  framing is visual before any prose runs.\n\n  Real brand SVGs via simple-icons for HubSpot / ClickUp / Notion /\n  Odoo / Zoho; hand-crafted three-bar mark for Monday (their brand\n  was removed from simple-icons under trademark policy).\n\n- **Honest-list columns** — the two side-by-side \"Where X is better\"\n  and \"Where Helios is better\" lists are now `card-lift` panels each\n  carrying a coloured top rule (warning for the competitor side,\n  primary for the Helios side) + a circular check / cross glyph per\n  bullet. Each panel header pairs the competitor / Helios glyph with\n  the eyebrow so the column's \"side\" is immediately readable.\n\n- **Pricing cards** — paired side-by-side with a \"vs\" pill between.\n  The Helios card carries a floating \"★ Best fit\" pill above its top\n  edge (same idiom as the home `CompetitiveMatrix`), 2px primary\n  border, and primary-tinted background. The competitor card stays\n  neutral.\n\nPlus `cta-lift` on both hero CTAs.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-compare-page-redesign.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"50321af6-500e-4006-aee2-e5d55b3620c6","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-ctafooter-polish","type":"changed","scope":"marketing","summary":"Polish the shared CtaFooter — atmospheric glow, cta-lift, larger heading.","body":"`<CtaFooter>` closes nearly every page on the marketing site (home,\nall 14 module pages, all 6 solution pages, all 6 compare pages,\nintegration pages, plus standalone pages). The previous treatment\nwas a small heading + plain buttons — visually slight for the page's\nlast impression.\n\nNow:\n\n- Primary radial back-glow rising from the bottom (mirrors the home\n  `ClosingCall` so every page closes with the same brand weight)\n- Heading upsized via `clamp(2rem, 3.6vw, 3.5rem)` line-height 1.05\n- `cta-lift` on both primary + secondary CTAs (1px hover rise)\n- Trust note now monospace uppercase with 0.16em tracking — same\n  treatment as the hero trust line, so the closing visual rhymes\n  with the opening\n- Section padding bumped from py-16 → py-20/sm:py-24 so the closing\n  beat has more breathing room\n\nInverse-tone variant (`tone=\"inverse\"`) keeps the accent background +\nskips the glow.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-ctafooter-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"d53e026b-d838-4741-b2f7-c86f8fd55a0e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-integration-page-faceoff","type":"changed","scope":"marketing","summary":"Integration-pages hero — Helios × partner face-off with real brand glyphs.","body":"The dynamic `/integrations/[slug]` pages share\n`<IntegrationPageTemplate>`. The hero used to be two 40px coloured\nsquares with the brand initial letters (\"H × S\" for Helios × Stripe) —\nvisually weak.\n\nNow:\n\n- Atmospheric primary radial back-glow across the hero\n- Real `<HeliosMark>` glyph in a 56px rounded square well with a\n  primary-tinted shadow\n- Partner's REAL brand glyph in a matching well (via simple-icons\n  lookup for ~18 common partners: Airtable, Anthropic, Calendly,\n  Cloudflare, Discord, GitHub, Gemini, HubSpot, Intercom, Linear,\n  Mailchimp, Notion, Plausible, Resend, Sentry, Stripe, Typeform,\n  Zapier). Falls back to a stylised display-font initial when we\n  don't ship the brand.\n- A small \"×\" pill between the two wells (border + monospace,\n  matching the compare-pages \"vs\" pill idiom)\n- Headline upsized via `clamp(2.25rem, 4vw, 4rem)` line-height 1.04\n- `cta-lift` on both CTAs","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-integration-page-faceoff.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"78fa94e3-d865-4ede-b918-90ffe20cb8dc","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-leader-tag-hero-links","type":"changed","scope":"marketing","summary":"Best-fit tag above Helios column + both hero secondary links rendered with animated arrows.","body":"Two surgical polish moves:\n\n- **§7a CompetitiveMatrix** — the Helios column now carries a small\n  floating \"★ Best fit\" pill above its header (primary border, primary\n  text, soft primary shadow) so the eye lands on the winning column\n  before reading any of the cells. The existing thin primary top-strip\n  is preserved underneath.\n\n- **Hero secondary links** — the bento hero was only rendering\n  `secondaryLinks[0]` and silently dropping the rest. Now maps over\n  every entry, separated by a thin dot divider. Each link wraps its\n  label in `link-underline-grow` so the underline grows from the left\n  on hover, plus the `→` glyph slides 4px right on hover (200ms).\n  Both \"See the product\" and \"Book a tour\" are now visible.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-leader-tag-hero-links.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"17c88c86-c5f4-48c2-9d75-0ffbf3d18f7d","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-module-page-hero","type":"changed","scope":"marketing","summary":"Module deep-dive hero — accent radial atmosphere + larger icon well + cta-lift.","body":"The 14 module deep-dive pages (Projects / CRM / HR / Recruitment /\nPayroll / Accounting / Sales / Support / Calendar / Forms / Email /\nExpenses / Chat / Inventory) share `<ModulePageTemplate>`. The hero\nwas a thin \"icon + eyebrow + headline + CTAs\" stack with no module-\naccent atmosphere — visually flat for the page that's meant to sell\nthat module.\n\nNow:\n\n- Module-accent radial back-glow across the top of the hero\n- SVG turbulence grain layer at 4% opacity, mix-blend-overlay, so the\n  glow has texture instead of looking like a flat color sheet\n- Glyph promoted from a 40px square chip to a 56px rounded square\n  well with a soft accent shadow and a 135deg accent-tinted background\n- Beta / Coming soon pill is now accent-coloured with a \"●\" prefix\n- Headline upsized via clamp(2.5rem, 4vw, 4.5rem); line-height 1.02\n- cta-lift on both Start free + Talk to sales buttons\n- Trust line below the CTAs in monospace tracking","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-module-page-hero.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"5284b37e-f42a-4596-b033-c010c36b5a18","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-47-d-chip-polish","type":"changed","scope":"projects","summary":"Task quick-prompt chips now render per-value glyphs and colors — Status chip shows the picked status icon in the trigger, Priority uses the semantic priority glyph, Assignee shows the picked user's avatar, dates get \"in 3d\" hints with overdue/soon tinting.","body":"R88.47 D — Polish sweep on the quick-prompt chips. Adds semantic\nglyphs + per-value colors + relative-time hints. The chips were\nfunctional after R88.47 C-HOTFIX; this pass makes them recognizable\nat a glance.\n\n## Status chip\n\n- **Trigger** shows the currently-picked status's `STATUS_ICON`\n  glyph (CircleDashed for backlog, Circle for todo, CircleHalf\n  for in-progress, Eye for in-review, CheckCircle for done,\n  XCircle for canceled) with the matching `STATUS_TONE` color.\n  Empty state still uses generic CircleHalf.\n- **Menu items** each render the option's own status glyph +\n  color. Selected item shows a `Check` on the right + `font-semibold`\n  label so the current pick is unambiguous.\n\n## Priority chip\n\n- **Trigger** shows the picked priority's `PRIORITY_ICON` glyph\n  (Warning for urgent, CaretDoubleUp for high, Equals for medium,\n  CaretDoubleDown for low, MinusCircle for none) with matching\n  `PRIORITY_TONE` color. Urgent renders with `weight=\"fill\"` for\n  extra visual weight.\n- **Menu items** mirror the trigger — each option has its own\n  glyph + color + Check on selected.\n\n## Assignee chip\n\n- **Trigger** shows the picked user's avatar (real image, or\n  initials-in-circle fallback) alongside their name. Empty state\n  still uses generic UserCircle glyph. Cleaner mental model than\n  \"generic person icon + name\" because operators recognize\n  teammates by face + name simultaneously.\n\n## Date chips (Start / Due)\n\n- **Populated chip** appends a relative-time hint next to the\n  date (`Jul 17 · in 3d`, `Jul 10 · 2d ago`, `today`,\n  `tomorrow`, `yesterday`) when the date is within a 14-day\n  window either side of today. Far-future dates keep the compact\n  `Jul 17` alone.\n- **Per-status tint** — **overdue** dates get amber border +\n  fill + text (`accent-warning`); **soon** dates (within the\n  next 7 days) get accent border + fill (`accent-default`); else\n  neutral. Same three-tier signal design as the target-date chip\n  on the project detail header (R88.45 Phase 3) so the vocabulary\n  is consistent across the app.\n- **Timezone-invariant parsing** (append `T00:00:00Z` before\n  parsing) so a Jul 17 date reads as Jul 17 for operators in every\n  time zone. Full ISO surfaces on hover via `title`.\n\n## Template chip\n\n- (Already had multi-line MenuItems with title + description hint\n  from R88.47 C-2; no additional polish this pass.)\n\n## Design decisions worth being explicit about\n\n- **Reused existing STATUS_ICON / PRIORITY_ICON / STATUS_TONE /\n  PRIORITY_TONE maps** — no new icons or color choices per chip.\n  Same semantic vocabulary the board, list, grouped view, and\n  task detail sheet already use.\n- **Check on selected menu item** — small polish that operators\n  notice immediately when reopening the menu. Same pattern as most\n  modern dropdown menus.\n- **Avatar fallback to initials-in-circle** — consistent with the\n  UserAvatar primitive elsewhere. Fetch failure or missing image\n  never leaves a broken `<img>` glyph.\n- **Relative-time hint only within 14 days** — far-future dates\n  (a launch date 6 months out) don't need \"in 180d\" clutter;\n  operators know from the date alone. Same window used by the\n  target-date chip.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.456Z","updatedAt":"2026-07-14T02:28:40.456Z"},{"id":"85ab48b6-9b6c-4723-892c-2638b71b1f09","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-rail-draws-and-footer-mark","type":"changed","scope":"marketing","summary":"Animate the FirstWeekTimeline + CascadeDiagram rails; add HeliosMark glyph to the footer.","body":"Two \"the action is happening\" moments on the home page now actually\n*flow*:\n\n- **FirstWeekTimeline** — the vertical rail beside the day-by-day\n  cards animates from 0 height when the section enters view\n  (1100ms cubic-bezier, transform-origin top), so the timeline\n  reads as \"being drawn\" from Day 1 down to Day 7.\n- **CascadeDiagram** — the rail draws down the same way, plus each\n  cascade node pops in with a small bounce (`cascade-node-pop` —\n  scale 0.4 → 1.18 → 1, 500ms) staggered 130ms apart top-to-bottom.\n  The trigger node retains its outward primary pulse on top. The\n  cascade now visually \"lands\" row by row, matching the rail draw.\n\nPlus the footer pairs the same `<HeliosMark>` glyph the top-nav\nfallback uses, so the page is bookended by the brand mark on both\nends (only shown when the operator hasn't uploaded a custom logo).\n\nBoth rail animations honor `prefers-reduced-motion`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-rail-draws-and-footer-mark.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"12e52270-145a-4766-9901-12224e6fe2e4","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-solution-page-polish","type":"changed","scope":"marketing","summary":"Solution-pages hero gets atmosphere; pain frame becomes an editorial callout.","body":"The 6 by-persona pages (Founder / Sales / Ops / People / Finance /\nMidmarket) share `<SolutionPageTemplate>`. Two visual upgrades:\n\n- **Hero** — primary radial back-glow across the top, headline upsized\n  via `clamp(2.5rem, 4.4vw, 5rem)` with line-height 1.02, and\n  `cta-lift` on both CTAs.\n- **Pain frame** — was plain prose under an eyebrow. Now an editorial\n  callout: left primary rule, hanging open-quote glyph outside the\n  rail (NYT-magazine idiom matching the home `<PullQuote>`), and the\n  pain text set in `font-display italic` instead of body sans. The\n  persona's pain reads as a recognised observation, not a wall of\n  text.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-solution-page-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"97333c2f-4276-4188-97ba-8e5fad86b97b","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-43-p2-phase3-milestone-name","type":"changed","scope":"projects","summary":"Task activity log now shows \"changed milestone 'Q3 launch' → 'Q4 launch'\" for milestone changes, and \"removed from milestone 'Q3 launch'\" when clearing.","body":"R88.43-P2 Phase 3 — Extends prior-state deltas to milestone changes,\ncombining the R88.43-P2 output-based pattern with the R88.43-P3\nname-lookup enrichment.\n\n## Handler (modules/projects/src/actions/milestone.ts)\n\n`setTaskMilestone` — the pre-existing task self-lookup now also\nreads `milestoneId`, so `prior.milestoneId` fills without an extra\nquery. Returned via `output.prior.milestoneId` on the existing\n`MilestoneMutationOutput` (widened with optional `prior`).\n\n## Server enrichment (task-activity-list.ts)\n\nThe activity list collect-and-resolve step now also gathers the\nprior milestone ID from `output.prior.milestoneId` and attaches the\nresolved name as `context.priorMilestoneName`. Same batched\n`projects_milestones` query — no extra round-trips.\n\n## Client render\n\n`describeActivity` for `set_milestone` follows a four-tier fallback:\n\n| Case | Phrase |\n|---|---|\n| Change (prior + new both resolved, differ) | `changed milestone \"Q3 launch\" → \"Q4 launch\"` |\n| Clear (prior resolved, new is null) | `removed from milestone \"Q3 launch\"` |\n| Link (only new resolved) | `linked to milestone \"Q4 launch\"` |\n| Neither name resolved | `linked to a milestone` / `removed from milestone` |\n\nOlder audit rows without `output.prior` or with a since-deleted\nmilestone name gracefully degrade to the earlier phrasing tiers.\n\n## Tests\n\n23/23 milestone tests + 4/4 activity-list tests pass. Two existing\nsetTaskMilestone tests updated for the new pre-fetch shape and\nassert `result.value.prior?.milestoneId` reads correctly:\n\n- \"attach\" sets prior to null (was unattached) → link path\n- \"detach\" sets prior to `PRIOR_MILESTONE` (was attached) → clear-\n  with-name path\n\n## Deferred\n\nSame list as Phase 2:\n\n- `set_section` / `set_cycle` / `set_parent` — need entity name\n  lookup (section names, cycle names, parent task number labels).\n  Follow the same pattern used here.\n- Multi-value fields (labels, watchers) — different delta shape.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.055Z","updatedAt":"2026-07-14T02:28:40.055Z"},{"id":"5d9e80c1-2f3b-4346-a2c8-9a0bef066753","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-46-p2-proprow-icons","type":"changed","scope":"projects","summary":"Task detail sheet's core property rows (Status, Priority, Assignees, Labels, Due, Start, Milestone, Cycle) gain a small leading icon so operators pattern-match rows by shape, not text.","body":"R88.46 Phase 2 — Row-level icons for the top eight properties in the\ntask detail sheet's right rail. Builds on Phase 1's redesigned\n`PropDivider` + `PropRow` with a matching leading-glyph vocabulary\ninside each core row.\n\n## PropRow icon prop\n\n`PropRow` now accepts an optional `icon` prop (any Phosphor node).\nRendered at 11px duotone in a flex-item before the label, with:\n\n- `text-[var(--fg-faint)]` at rest — quiet so the label still reads\n  as the primary content\n- `text-[var(--fg-muted)]` on row hover — brightens in lockstep with\n  the label's own `fg-muted → fg-default` color transition, so the\n  whole row-header cluster arms visibly as interactive\n\n## Icons applied (core section — above the Effort divider)\n\n| Row        | Glyph          |\n|------------|----------------|\n| Status     | `CircleHalf`   |\n| Priority   | `Flag`         |\n| Assignees  | `UserCircle`   |\n| Labels     | `Tag`          |\n| Due        | `CalendarBlank`|\n| Start date | `CalendarPlus` |\n| Milestone  | `Diamond`      |\n| Cycle      | `Bookmark`     |\n\n## What's NOT changed\n\nSub-section rows (Effort, People, Automation, Metadata) stay\ntext-only. The section divider already carries a scanning glyph\n(Gauge, UsersThree, ClockClockwise, Info) — a per-row icon on top\nof that would be visual noise. The pattern: one glyph per section,\nexcept in the \"core\" band above the first divider where each row\ngets its own.\n\n## Design decisions worth noting\n\n- **Duotone weight, not fill** — matches Phase 1's section icons\n  and the \"reading surface\" tone of the whole panel. Fill would\n  compete with the value-cell affordances (pickers, chips,\n  inputs).\n- **11px, not the section's 12px** — row icons are one visual\n  hierarchy step below section icons. Same tone, smaller size\n  telegraphs \"child of the section\".\n- **`text-[var(--fg-faint)]` default** — icons are decoration that\n  reinforces the label, not the primary content. The label itself\n  wears the `fg-muted` weight; the icon sits one step quieter.\n- **Hover color-shift synchronized** — icon + label both step up\n  one tone (faint→muted, muted→default) at row-hover so the arm\n  vocabulary reads as unified across all row primitives.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.104Z","updatedAt":"2026-07-14T02:28:40.104Z"},{"id":"e7db60e8-818c-40a9-838c-2a32d9f8124f","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-47-c-hotfix-chips-not-working","type":"fixed","scope":"projects","summary":"Fix \"quick task pills options not working\" — property chips on the quick-prompt now open their pickers on click. Root cause: <Picker> is self-contained, but I wrapped it in a redundant conditional-render toggle.","body":"R88.47 C shipped property chips (Status / Priority / Assignees / Start\n/ Due / Template) on the quick-prompt, but the options weren't\nselectable — clicking a chip appeared to do nothing.\n\n## Root cause\n\n`<Picker>` from `@helios/ui` is a self-contained Radix Popover — the\ncomponent's trigger button opens the popover on click.\n\nMy R88.47 C ChipShell wrapped Picker inside a custom toggle:\n\n```tsx\n<button onClick={() => setOpen(v => !v)}>\n  {icon} {label}\n</button>\n{open && <Picker options={...} />}\n```\n\nClick flow:\n1. User clicks my chip button → `open` flips true\n2. React re-renders → `<Picker>` mounts\n3. User sees the Picker's OWN trigger button (which they haven't\n   clicked yet)\n4. User has to click again to open the actual options\n\nThe two nested triggers made the chip feel unresponsive — one click\ndid nothing visible unless the operator kept the mouse hovering long\nenough to spot the second button.\n\n## Fix\n\nRefactored each chip to open its dropdown directly on click, without\nthe extra toggle layer:\n\n- **StatusChip / PriorityChip / TemplateChip** — use `<Menu>` (Radix\n  DropdownMenu) with the chip button as `<MenuTrigger asChild>`. Menu\n  items render the options. One click → menu opens → click an item →\n  value set. Type-ahead search comes free from Radix.\n- **AssigneeChip** — keeps `<Picker>` (needs search over the full org\n  member list) but drops the redundant ChipShell wrapper. Picker's\n  trigger IS the chip; `renderTrigger` prop draws the pill styling,\n  `className` overrides the default field-model chrome (border, bg,\n  padding, width).\n- **DateChip** — uses a chip button + a hidden native\n  `<input type=\"date\">`. Clicking the button calls `showPicker()` on\n  the input, which opens the browser's calendar UI. Falls back to\n  focus + space/enter on browsers without `showPicker()` (very old\n  Safari). Simpler than a portal-anchored input.\n- **ChipClearButton** — extracted the `×` clear affordance as a\n  sibling of the trigger (not nested inside) so its click doesn't\n  bubble to the parent trigger.\n\n## Design decisions worth being explicit about\n\n- **Menu (DropdownMenu) chosen over Picker for enum chips.** Radix\n  Menu's type-ahead search covers small enums (6 statuses, 5\n  priorities) without needing the search-input chrome. Menu is\n  simpler, more consistent with our TaskRowMenu, and doesn't have\n  the nested-trigger issue.\n- **Picker kept for Assignee.** Long lists (~50+ users typical) need\n  the always-visible search input.\n- **Native `showPicker()` for dates.** Portal-anchoring a date input\n  to the chip position with the popover open/close flow is fussy;\n  invoking the browser's native picker directly is cleaner + more\n  reliable.\n- **No visual regression.** The chip pill styling (dashed border\n  empty / solid border populated / icon + label + optional ×) is\n  preserved — only the click plumbing changed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.101Z","updatedAt":"2026-07-14T02:28:40.101Z"},{"id":"bfb0ed30-5e5a-4655-a6b8-16486db05a72","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-47-c-hotfix2-zindex","type":"fixed","scope":"projects","summary":"Fix \"State / Priority / Assignee not working\" on the quick-prompt chips — root cause was a z-index conflict between the chip popovers (z-60) and the parent Modal (z-70).","body":"R88.47 C hotfix #2. The first hotfix (`ed333952`) fixed the nested-\ntrigger issue for enum chips, but a second layer of the same symptom\nremained: the chip popovers opened *underneath* the Modal's overlay\nand clicks couldn't reach them.\n\n## Root cause\n\n`@helios/ui`'s Modal (Radix Dialog) uses `z-[70]` for both its\noverlay and its content (per Phase 9.4.x.78 bump — modals stacked\nabove Drive's file-viewer modal).\n\nThe Menu (DropdownMenu) primitive defaults to `z-[60]` on its\ncontent. The Picker's popover also defaults to `z-[60]`. Both were\nset with the assumption that Modal was `z-[50]` — which was the case\nbefore the Phase 9.4 bump.\n\nResult: chip menus / popovers rendered under the modal overlay. The\noverlay caught clicks, the menu never received them, and to the\nuser it looked like \"clicking the chip does nothing.\"\n\nDates worked because the `DateChip` calls\n`inputElement.showPicker()` — the browser's native calendar picker\nuses the OS's own overlay, not the DOM z-stack.\n\n## Fix\n\nOverride the z-index on every chip's popover to `z-[80]` (above\nModal's `z-[70]`):\n\n- **StatusChip / PriorityChip / TemplateChip** — `<MenuContent\n  className=\"z-[80]\">`. Template's existing `max-w-[280px]` merged\n  into `z-[80] max-w-[280px]`.\n- **AssigneeChip** — `<Picker contentClassName=\"z-[80]\">` (Picker\n  exposes `contentClassName` for exactly this override case).\n- **DateChip** — no change needed; native `showPicker()` is unaffected.\n\n## Why not fix at the primitive level\n\nBumping Menu / Picker defaults to `z-[80]` would ripple across\nevery consumer — some of which may be inside surfaces at higher\nz-index than Modal (nested modals, sheets over sheets). Case-by-\ncase override at the callsite is safer for now.\n\nIf chip-in-modal becomes a common pattern, a `variant=\"in-modal\"`\nprop on Menu / Picker could centralize the override. Held pending\nthe pattern's frequency.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.314Z","updatedAt":"2026-07-14T02:28:40.314Z"},{"id":"4f911eb5-0853-4b18-ba05-5535e6a04dfa","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-diagramflow-pill-polish","type":"changed","scope":"marketing","summary":"Polish DiagramFlow + Pill primitives.","body":"- **DiagramFlow** (the `/ai` action-layer + legacy CMS pages): each\n  step card now carries a 2px accent top-stripe in the module accent\n  (or primary if module is unset), card-lift hover with a soft\n  accent-tinted shadow, and the last step gets a small accent\n  pulse-dot. Arrows between steps gain `.chevron-glide` (the same\n  2.6s right-and-back loop the home PricingComparisonBar uses) so\n  the diagram has directional energy. Mobile stacks vertically with\n  a chevron pointing down between steps.\n\n- **Pill** (primitives): interactive pills now 1-px-lift on hover\n  with a primary-tinted background + soft primary shadow (eased\n  200ms). New `tone='primary'` variant for \"active\" / selected\n  filter-chip state (brand-tinted bg + border + text).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-diagramflow-pill-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"72cb5394-fbf3-4105-9a30-d294e8a9e55c","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-48-a-row-context-menu","type":"added","scope":"projects","summary":"Task rows in list, board, and grouped views gain a kebab context menu (⋯) with Open · Open in new tab · Copy link · Duplicate · Archive · Delete — power-user actions inline, no detail-sheet detour.","body":"R88.48 A — Row-level context menu. Inspired by Plane's row context\nmenu (the one that opens Edit / Make a copy / Open in new tab / Copy\nlink / Archive / Delete). We build the same six-item vocabulary as a\nreusable component and drop it into every task-list surface at once.\n\n## What ships\n\nNew `<TaskRowMenu>` component in\n`apps/web/src/components/widgets/task-views/task-row-menu.tsx`.\nStandalone kebab-triggered `<Menu>` (Radix DropdownMenu) with six\nitems:\n\n| Item | Action |\n|---|---|\n| **Open** | Same as clicking the row — opens the detail sheet. |\n| **Open in new tab** | Real `<a target=\"_blank\">` on `/projects/$projectId/tasks/$taskId`. Middle-click, Cmd+Click, right-click all keep native behaviour. |\n| **Copy link** | Absolute URL to the task, `navigator.clipboard.writeText`. Toast confirms `Copied link to TEAM-42`. |\n| **Duplicate** | `projects.task.duplicate` action. Toast reports the new number label. |\n| **Archive** / **Unarchive** | Toggles based on `isArchived` prop — same slot serves both lifecycle verbs. |\n| **Delete** | Fires a `ConfirmDialog`; on confirm calls `projects.task.delete` (soft-delete, 30-day restore). |\n\n## Wired into three surfaces\n\n- **List view** — added to the task row in `$projectId.index.tsx` as the\n  last flex child. Kebab hover-revealed on desktop\n  (`lg:opacity-0 lg:group-hover/row:opacity-100`), always-visible on\n  touch (drops the opacity gate under-lg).\n- **Board view** — added to the card as an absolute-positioned span in\n  the top-right corner (`top-1.5 right-6`, offset from the freshness\n  pulse). Uses `hoverGroup=\"card\"` prop so the kebab watches the\n  card's `.group/card` hover state instead of the `.group/row` used\n  in list/grouped.\n- **Grouped view** — added to the row as the last flex child, same\n  shape as the list view.\n\n## Design decisions worth being explicit about\n\n- **Kebab-triggered, not right-click-triggered (for now).** Radix's\n  DropdownMenu anchors to its trigger; right-click would need the\n  separate ContextMenu primitive + position anchoring. Ship the\n  simpler affordance first; add right-click if operator complaints\n  surface.\n- **Hover-visible on desktop, always-visible on touch.** At rest the\n  kebab is invisible under `lg` breakpoint so rows read clean;\n  under-lg we drop the gate so touch users can always tap.\n- **Focus-within also reveals the kebab.** Keyboard nav (Tab into a\n  row) makes the kebab visible without needing hover, so keyboard\n  users can reach the menu without a mouse.\n- **`Delete` sits at the bottom with an accent-danger tint** — muscle\n  memory + colour weight lower the accidental-click risk. Server-side\n  `dangerous: true` gate on `projects.task.delete` still runs.\n- **Undo isn't provided in the menu** — Archive is reversible via the\n  detail sheet (\"Restore\" button appears on archived tasks); Delete\n  is soft (30-day window), restorable from the same sheet or from\n  the future recycle-bin UI. This menu just fires + toasts.\n- **`Open in new tab` uses a real anchor**, not `window.open`. Keeps\n  middle-click / right-click / Cmd+Click behaviour intact — a\n  JS-driven open would break those.\n- **`hoverGroup` prop** — small typographic knob: list rows use\n  `group/row`, board card uses `group/card`, so the menu adapts to\n  its parent's Tailwind group name via the prop. Zero runtime cost,\n  keeps the component drop-in-anywhere.\n\n## What's NOT included yet\n\n- **Right-click / native context menu.** Deferred — Radix's\n  ContextMenu primitive + custom position anchoring is a follow-up.\n- **\"Make a copy to another project\"** — Plane's sub-menu with a\n  project picker. Our `projects.task.duplicate` action currently\n  same-project only. Widening to cross-project needs a section /\n  cycle / assignee remap decision. Queued for R88.48 A-2.\n- **Calendar + Gantt views** — these have their own row/card\n  vocabulary (task cards on a calendar, bars on a timeline). The\n  menu can drop in there later but the placement design is\n  different for each.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.473Z","updatedAt":"2026-07-14T02:28:40.473Z"},{"id":"8831dbaa-902c-4e0d-9107-7fdc0d40e5f1","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-a11y-and-scroll-polish","type":"changed","scope":"marketing","summary":"Phase 9 — a11y polish (skip-link, scroll-margin-top, smooth-scroll with motion-pref).","body":"- **Skip-to-content link** — was a bare keyboard-only \"Skip to\n  content\" badge. Now matches the brand idiom: primary background +\n  primary-tinted shadow + focus ring + a small `↓` glyph hinting\n  what the action does. Still `sr-only` until focused.\n- **Scroll-margin-top** — every element with an `id` (every section\n  anchor in MDX prose, every `<details>`, every `#hash` target) now\n  carries `scroll-margin-top: 5rem` so anchor jumps land below the\n  sticky top nav (64px nav + breathing offset) instead of clipping\n  the section's eyebrow.\n- **Smooth-scroll** — set at the HTML level so internal anchor jumps\n  glide instead of teleport. Overridden to `auto` inside\n  `@media (prefers-reduced-motion: reduce)` so reduced-motion users\n  get instant jumps as expected.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-a11y-and-scroll-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"019f04c2-86c3-4c99-8e31-fa3593dcd2ef","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-ai-page-polish","type":"changed","scope":"marketing","summary":"Polish /ai page — atmospheric hero, staggered autonomy tiers, designed use-case + cannot-do lists.","body":"The `/ai` page sells the central differentiator. Now it carries the\nbrand weight to match.\n\n- **Hero:** gains the primary radial back-glow + dotted-grid texture\n  treatment other page heroes use. CTAs upgraded to `cta-lift` +\n  `data-magnetic`.\n- **Three autonomy levels (ASK / PROPOSE / EXECUTE):** parent wrapped\n  in `.reveal`, `<FeatureGrid stagger>` so the three tiers cascade\n  in. FeatureTile polish (card-lift, accent strip, icon backplate)\n  flows automatically.\n- **What it does today (10 use cases):** plain bullet rows replaced\n  with `card-lift` cards (border-primary/40 on hover + soft primary\n  shadow) in a `reveal-stagger` grid — list reads as 10 concrete\n  shipped behaviors instead of a wall.\n- **What we don't pretend (6 cannot-do bullets):** danger-tone\n  treatment — each item becomes a card with a circular danger-tinted\n  × glyph in a `danger/15` backplate + border-danger/30 hover state.\n  Architectural limits read as deliberate guarantees, not\n  apologies.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-ai-page-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"daa64347-cbb7-4890-bd0b-ea38d3b675ad","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-blog-layout-prose-polish","type":"changed","scope":"marketing","summary":"Polish BlogLayout — atmospheric hero, gradient tag chips, designed prose-helios typography.","body":"Blog posts inherit BlogLayout — one fix elevates every post.\n\n- **Hero atmosphere:** the article header wraps in a relative isolate\n  with a primary radial back-glow (40% height, capped at 480px) so\n  every post opens with the same brand weight as the rest of the site.\n- **\"← Blog\" trail link** above the tags so readers have an obvious\n  exit before the wall of prose. Uses `.link-underline-grow` with\n  monospace tracking.\n- **Tag chips** now ship the primary-tinted gradient + monospace\n  tracking (matching the changelog release-tag treatment).\n- **Author byline** gains a small primary accent dot prefix; dates\n  use monospace tabular for alignment; divider dots tint to border\n  color.\n- **Cover image** gains a soft shadow + rounded-xl border.\n\n`.prose-helios` styling — every MDX-rendered blog post inherits:\n\n- **H2 headlines** gain a small primary tick mark above them\n  (1.5rem × 2px rounded primary rule) — vertical rhythm beat without\n  competing with the headline. Tighter `letter-spacing`.\n- **Inline links** animate from 0→100% underline width on hover/focus\n  (matches the site-wide `.link-underline-grow` utility, inlined so\n  the MDX pipeline doesn't need to know about the class).\n- **Bullet lists** swap default markers for a small primary disc\n  (0.375rem rounded, primary/60), gives lists a brand voice.\n- **Blockquotes** now use `font-display italic` at 1.125rem on a\n  1.55 line-height — reads like a pull quote, not a sidebar.\n- **Code blocks** gain a soft drop-shadow.\n- **HR** rules are now a gradient hairline (transparent → border →\n  transparent) for a softer section break.\n\nAll animations honor `prefers-reduced-motion`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-blog-layout-prose-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"ea7c66b6-1ee0-4a2e-af84-5528f48bccbe","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-brand-page-polish","type":"changed","scope":"marketing","summary":"Polish /brand — atmospheric hero, card-lift on logo + color swatch cards.","body":"The brand-assets page is the press kit. It should feel like a real\ndesign reference, not a static download list.\n\n- Hero gains the primary radial back-glow other page heroes use.\n- Logo system cards (Wordmark light, Wordmark dark, Glyph) now ride\n  `card-lift` with brand-tinted hover shadows. The Download buttons\n  pick up `cta-lift`. Preview tiles inside each card now sit on a\n  subtle `bg-surface/40` backplate so the wordmark/glyph reads as a\n  \"specimen\" instead of floating.\n- The Glyph preview tile gains a soft primary drop-shadow so the\n  brand mark has depth on its specimen tile.\n- Color swatch cards (3 palettes × multiple swatches) gain\n  `card-lift` with a soft fg-tinted hover shadow + border-fg/30\n  hover — clicking-to-copy interaction should feel responsive.\n- Logo system + swatch grids both wrap in `.reveal` containers with\n  `.reveal-stagger` so cards cascade in.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-brand-page-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"8288e072-1ee2-4614-a4e3-ba9c166f0c9e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-breadcrumb-jsonld-coverage","type":"changed","scope":"marketing","summary":"Phase 12 — BreadcrumbList JSON-LD on 11 standalone marketing pages.","body":"Before: only the home, pricing, integrations/[slug], product/[slug],\nand status/incidents/[slug] pages emitted JSON-LD. Google + Bing\nsaw the marketing site as a flat list of pages with no hierarchy.\n\nAfter: every public standalone page emits a `BreadcrumbList` schema\ngraph through the `<PageLayout jsonLd>` slot.\n\nPages now covered:\n`/about`, `/customers`, `/trust`, `/partners`, `/developers`,\n`/careers`, `/ai`, `/brand`, `/contact`, `/integrations`, `/product`\n\nThis means each page advertises its place in the site hierarchy to\nsearch engines (Home → /<page>), enabling rich breadcrumb snippets\nin result pages. Combines with the existing `Organization` /\n`WebSite` / `SoftwareApplication` schemas on the home for a complete\ngraph.\n\nThe breadcrumb root resolves dynamically from\n`branding.marketingUrl` (falling back to `canonical` with the page\nsuffix stripped), so white-label deployments emit the operator's\ndomain, not heliosworks.com.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-breadcrumb-jsonld-coverage.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"010c1e43-0658-4aa1-925d-91456f00a356","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-49-a-board-placeholder-pills","type":"changed","scope":"projects","summary":"Board card footer now renders subtle placeholder chrome for empty start-date, due-date, and assignee slots on hover — operators see \"click here to set\" affordances instead of missing-field ambiguity.","body":"R88.49 A — Board card placeholder pills. Inspired by Plane's always-\nvisible date + assignee slots on their cards. Ours takes the same\nUX-research insight (empty ≠ absent — always show the affordance)\nbut pairs it with a hover reveal so resting cards stay clean.\n\n## What ships\n\n**Placeholder Start-date pill** — when `startsAt` is unset, a dashed-\nborder pill with the same Clock glyph renders in the footer, hidden\nat rest (`opacity-0`) and revealed on card hover\n(`group-hover/card:opacity-100`). Same width footprint as the\npopulated pill so hover doesn't shift the layout.\n\n**Placeholder Due-date pill** — same pattern, dashed border + CalendarBlank\nglyph, hover-visible only.\n\n**Placeholder Unassigned slot** — the assignee stack already renders a\ndashed-circle unassigned marker when `showUnassigned=true`. Was set to\n`false` on the board (hidden entirely). Now flips to `true` when no\nassignees are present + wraps the slot in the same hover-reveal\nopacity pattern.\n\n**Footer allows wrap** — added `flex-wrap` on both the outer footer\nrow and the inner metadata cluster. When labels + dates + counters\npile up on a narrow card, the assignee stack can now drop to a\nsecond line instead of being pushed off-screen.\n\n## Design decisions\n\n- **Hover-reveal, not always-visible.** Plane's approach shows\n  placeholders at rest which reads as \"busy\" on cards with sparse\n  metadata. Our compromise: reveal on hover so the discoverability\n  win is intact but resting cards stay quiet.\n- **Same width when populated.** The placeholder + real pill occupy\n  the same horizontal space so `hover → show placeholder` doesn't\n  push adjacent pills sideways.\n- **Dashed border + faint fg** — same visual language as the\n  create-modal's \"+ Add description\" chip and the empty-Metadata\n  chrome across the module. Reads as \"slot waiting for content.\"\n- **Only board view for now.** Grouped view + list view already have\n  inline editors (`InlineDueDateEditor`, `InlineAssigneeEditor`)\n  that render their own always-visible dashed chrome; that pattern\n  was already the design. R88.49 A brings the board card up to the\n  same level.\n\n## What isn't included\n\n- **Click-to-edit on placeholder pills** — currently they're\n  aria-hidden decoration. Wiring them to open the appropriate picker\n  is a nice follow-up but needs the picker refactor first (they're\n  currently sheet-scoped, not portable).\n- **Milestone / cycle placeholder pills** — same idea but the\n  attach action needs the milestones/cycles catalog client-side,\n  which is a larger refactor. Held for R88.49 A2.\n- **Always-visible mode.** If operators say the hover-only reveal is\n  too shy, a density-toggle-driven \"show placeholders at rest\" mode\n  is a small follow-up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.671Z","updatedAt":"2026-07-14T02:28:40.671Z"},{"id":"feda539d-92e7-40aa-95b3-97c24018d9b7","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-dynamic-robots-txt","type":"changed","scope":"marketing","summary":"Phase 13 — robots.txt is now dynamic per deployment, fixes white-label leak.","body":"Before: `/public/robots.txt` hard-coded\n`Sitemap: https://heliosworks.com/sitemap-index.xml`. A white-label\ndeployment (an operator running `envoyos.com`) would still point\ncrawlers at heliosworks.com's sitemap. Violates the no-static-\nbranding rule.\n\nAfter: `/src/pages/robots.txt.ts` is an Astro SSR endpoint that\nreads `loadBranding({ request })` and emits the operator's own\n`marketingUrl` as the `Sitemap:` line. Falls back to the request\n`Host` header when no `marketingUrl` is configured.\n\nKept: AI-crawler allow-list (GPTBot, ClaudeBot, anthropic-ai,\nGoogle-Extended, PerplexityBot, CCBot) per Doc 13 §4 — we license\nrather than block.\n\nPlus `/preview/` is now disallowed (was missing; preview routes are\ninternal-only).\n\nCache header: `Cache-Control: public, max-age=3600`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-dynamic-robots-txt.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"d8b8c06c-a301-4892-b477-8ec11d8dd88b","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-empty-states-print-polish","type":"changed","scope":"marketing","summary":"Phase 10 — designed empty states (blog, changelog, status) + print stylesheet + moz selection.","body":"- **/blog index empty state** — was a one-line \"No posts yet.\" Now a\n  dashed-border panel with a list-icon disc, \"First note lands when\n  the team has something honest to share. Subscribe via RSS so you\n  don't miss it.\" Reads as honest disclosure, not broken site.\n- **/changelog empty state** — same panel treatment with a clock-icon\n  disc and \"Changelog warming up.\" + RSS link.\n- **/status \"No active incidents\"** — bare paragraph replaced with a\n  success-themed panel: success/15 backplate + check-shield glyph +\n  \"Every component is operational. Subscribe below to be notified.\"\n  Reads as a confident \"we're up\", not silence.\n- **::-moz-selection** added alongside `::selection` so Firefox gets\n  the primary-tinted text-selection highlight too.\n- **Print stylesheet** — drops scroll-progress, bento back-glows,\n  hero tickers, nav, footer. Shows `(URL)` after every link so paper\n  copies of trust/legal/changelog pages stay useful.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-empty-states-print-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"2ea2b5de-7c43-4281-a9a6-26cb71a3ac80","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-error-pages-polish","type":"changed","scope":"marketing","summary":"Polish 404 + 500 error pages with editorial typography, atmospheric back-glow, cta-lift + magnetic CTAs.","body":"Error pages are often the first impression after a broken external\nlink — they deserve the same brand weight as a normal landing page.\n\n- **404** — gains a primary radial back-glow, an editorial 404\n  numeral at `clamp(6rem, 16vw, 12rem)` with a soft primary text-\n  shadow drop, `cta-lift` + `data-magnetic` on \"Back to home\", and\n  the support link uses `.link-underline-grow`.\n- **500** — same treatment with the danger tone (radial atmosphere +\n  text-shadow drop on the numeral), so it stays serious without\n  being alarming. `cta-lift` on both CTAs, magnetic on the primary.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-error-pages-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"e77b9679-9cb4-465d-bfa7-4093caaa8cdd","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-faq-comparisontable-polish","type":"changed","scope":"marketing","summary":"Polish FAQ + ComparisonTable to match home page idioms.","body":"Two more shared blocks elevated to match the home page's polished\nidioms:\n\n- **FAQ** (generic, used on pricing + compare pages): replaces the\n  one-card-divided-list with one-per-row cards, each in a `<details>`\n  with editorial Q.01/Q.02 numbering on the left, the +/- glyph that\n  rotates 45° to become × on open, primary border + soft primary\n  shadow when open, soft surface body when answered. Matches the\n  home `FaqAccordion` look pixel-for-pixel.\n\n- **ComparisonTable** (pricing + compare pages): matches the home\n  CompetitiveMatrix polish:\n  - Floating \"★ Best fit\" pill above the highlighted column\n    (caller passes `highlightLabel=\"Most popular\"` etc. to override).\n  - Highlighted column gets a 2px primary top-stripe + soft primary\n    tint that deepens on row hover.\n  - ✓ cells in the highlighted column render in primary (vs\n    success-green for other columns) so the eye lands there first.\n  - Row hover lights the whole row in surface tint.\n  - Mobile swipe hint banner above the scroll container.\n  - Sticky first column + header with backdrop-blur.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-faq-comparisontable-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"6628e6b5-d243-4e86-9f0e-5d78338332bb","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-form-primitives-polish","type":"changed","scope":"marketing","summary":"Polish form primitives (Input / Textarea / Select / Checkbox) with hover, focus glow, and invalid-state tint.","body":"The four form primitives that compose the contact form, signup, ROI\ncalculator, and any future marketing form now share a consistent\ninteraction language:\n\n- **Hover:** border lifts from `border` (~muted) to `fg/25` (~stronger\n  neutral), 200ms ease — gives the input a tactile \"this is\n  interactive\" affordance before focus.\n- **Focus:** border tints `primary`, plus a soft `primary/30`\n  ring-2 (replaces the old offset-ring) so focus reads as a brand\n  moment instead of accessibility-only.\n- **Invalid:** border + focus ring both tint `danger/30` so error\n  state is unmistakable even mid-typing.\n- **Checkbox checked:** gains a small primary shadow + the check\n  glyph scales 50→100 (150ms) instead of just opacity-fading, so\n  the affirmative action feels confirmed.\n- Transitions throttled to `[border-color, box-shadow,\n  background-color]` (no `transition-all`) — keeps animations from\n  fighting layout.\n\nHonors `prefers-reduced-motion` via the shared CSS reset.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-form-primitives-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"44e39d07-5cac-43e7-9dfe-84baaf1c6135","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-img-dimensions","type":"performance","scope":"marketing","summary":"Phase 17 — explicit width/height + fetchpriority on every <img>, eliminates CLS.","body":"Cumulative Layout Shift is a Core Web Vital. Every `<img>` without\nexplicit `width`/`height` triggers a reflow when the image loads —\nthe browser has no way to reserve space until it has bytes.\n\nThis pass adds `width` + `height` (the intrinsic aspect, not the\nvisual size — `className=\"size-6\"` still controls display) to every\n`<img>` in the marketing app:\n\n- **TopNav + MobileNav** — operator logo (24×24).\n- **BlogLayout cover image** — 1200×630 (matches our OG card aspect\n  for consistency), with `aspect-[1200/630]` so the placeholder slot\n  is reserved at the correct ratio + `object-cover` so any non-1200\n  source crops cleanly. Adds `fetchpriority=\"high\"` since the cover\n  image is the post's LCP candidate.\n\nCuts CLS for the blog and any branded operator deploy from\nnon-trivial to ~0.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-img-dimensions.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"06745459-c09f-4ac5-a699-3c4098f86293","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-interactive-primitives-polish","type":"changed","scope":"marketing","summary":"Polish ThemeToggle / NewsletterSignup / ContactForm / StatusSubscribeForm / SearchPalette interactions.","body":"Five interactive components that visitors actually touch — every one\nnow ships with consistent micro-interactions.\n\n- **ThemeToggle** — both Sun and Moon icons live in the DOM at once,\n  stacked. Cross-fade with scale + rotate (300ms cubic-bezier) on\n  toggle instead of swap. Hover: 1px lift + primary-tinted shadow.\n  `aria-pressed` reflects state. Sun spins 45° + tints warning-500 on\n  hover; Moon spins -12° + tints primary on hover.\n- **NewsletterSignup** — success state gets a designed treatment:\n  primary border-2, success/15 backplate icon disc with a\n  `CheckCircle2` glyph, soft success radial back-glow, role=status\n  for screen readers. Submit button picks up `.cta-lift` + animated\n  spinner during submit.\n- **ContactForm** — same success card treatment (size-9 disc + radial\n  glow + role=status). Submit button gets `.cta-lift` + spinner.\n- **StatusSubscribeForm** — same success card treatment, with the\n  `Mail` icon shown when verification is required vs `CheckCircle2`\n  when no verification needed. Submit button gets `.cta-lift` +\n  spinner.\n- **SearchPalette** — trigger button gains 1px hover lift + primary\n  shadow + primary tint on the Search glyph + ⌘K kbd. Dialog adds\n  a soft primary radial back-glow + deeper shadow. Close (`X`)\n  rotates 90° on hover. \"Searching…\" gets a spinning primary indicator.\n  Internal \"contact us\" link uses `.link-underline-grow`.\n\nAll animations honor `prefers-reduced-motion`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-interactive-primitives-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"29646e0b-c51c-4d5c-9d51-a97b075135d5","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-layout-imports-restore","type":"fixed","scope":"marketing","summary":"Restore layout imports stripped by the Biome organizeImports sweep.","body":"The repo-wide `biome --write` sweep (`c416a7f5`) ran organizeImports\non every Astro layout. Astro's hybrid frontmatter + JSX body model\ndoesn't play with Biome's standard organizeImports rule: components\nused only in the JSX body (never in the `.ts` frontmatter) were\nsilently deleted as \"unused\"; components used as both a Type and a\nJSX value were narrowed to `import type` (which strips at runtime).\n\nThis restores the imports for the five layouts:\n\n- `PageLayout.astro` — `BaseLayout`, `TopNav`, `Footer`,\n  `NewsletterSignup`, `Container`\n- `BaseLayout.astro` — `ClientRouter`, `JsonLd`\n- `BlogLayout.astro` — `PageLayout`\n- `LegalLayout.astro` — `PageLayout`, `Section`, `Container`, `Eyebrow`\n- `ModuleLayout.astro` — `PageLayout`\n\nThe page-level files (`index.astro`, `pricing.astro`, the compare/*\npages, etc.) still have the same damage and need a separate restoration\npass — but with the layouts repaired any future page-level fix at\nleast has a foundation to render on.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-layout-imports-restore.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"bd2b82b7-6cc0-49a4-831e-152856fed666","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-legal-layout-polish","type":"changed","scope":"marketing","summary":"Polish LegalLayout — atmospheric header, \"← Trust center\" back-link, designed legal-prose typography.","body":"Legal pages (privacy, terms, DPA, sub-processors, security,\ncompliance, HIPAA, etc.) all inherit LegalLayout. One fix elevates\nthe entire trust-tier surface.\n\nHeader chrome:\n- Soft primary radial back-glow above the article (32% height,\n  primary/0.08 opacity — even more restrained than blog so legal copy\n  reads serious).\n- \"← Trust center\" back-link at the top with `.link-underline-grow`\n  + monospace tracking, gives readers an obvious exit.\n- \"Last updated:\" line gains a small primary accent dot prefix +\n  monospace tabular date.\n\nlegal-prose MDX styling now mirrors the blog-prose treatment:\n- H2 + H3 get `scroll-margin-top: 6rem` so anchor jumps land below\n  the sticky nav (matters for /privacy + /security where users\n  deep-link sections like #cookies, #dangerous-actions).\n- H2 gains the primary tick mark above (matches blog prose).\n- Bullet lists swap default markers for a small primary disc.\n- Inline links animate from 0→100% underline width on hover/focus\n  (matches the site-wide pattern).\n- Tables now `border-radius: md` with `overflow: hidden`, uppercase\n  th headers + row hover. Looks like a designed spec sheet instead\n  of a raw HTML table.\n- Blockquote treatment matches blog prose.\n- HR rules now a gradient hairline.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-legal-layout-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"173c67eb-ae5c-4124-b0c3-65315e5eb87a","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-link-grow-site-wide","type":"changed","scope":"marketing","summary":"Replace every remaining `text-primary hover:underline` link with `.link-underline-grow` for site-wide consistency.","body":"Site-wide sweep: every prose inline-link that was using the old\n`text-primary hover:underline` pattern is now `.link-underline-grow`\n— underline animates from 0%→100% width on hover/focus from the\nleft (280ms cubic-bezier).\n\nPages updated: `/about`, `/customers`, `/ai`, `/brand`, `/careers`,\n`/changelog`, `/developers`, `/blog/index`, `/pricing/calculator`.\n\nComponents updated: `WebsitePageRenderer`, `ComparePageTemplate`,\n`PricingPageTemplate`.\n\nPlus `/customers` and `/about` heroes gain the same primary radial\nback-glow other heroes carry, and `/about` Principles grid gets\nreveal-stagger via `<FeatureGrid stagger>` inside a `.reveal` Stack.\n\nThe Button `link` variant intentionally keeps the plain underline\nsince that's the semantic affordance for `<Button variant=\"link\">`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-link-grow-site-wide.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"12642d53-0a96-4707-81fb-fddeca6e3d23","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-link-primitive-polish","type":"changed","scope":"marketing","summary":"Polish Link primitive — animated underline grow + external icon fades in on hover.","body":"The `<Link>` primitive (used for prose links in MDX content, blog\nposts, docs, fine print) now matches the home-page anchor idiom:\n\n- `.link-underline-grow` — underline grows from 0% to 100% width on\n  hover/focus from the left (animated via `background-size`, not\n  `text-decoration`), 280ms cubic-bezier.\n- The trailing external-link icon sits at 60% opacity by default\n  and fades to 100% on hover via a `group/link` selector, so the\n  icon doesn't visually weigh down the link in body copy.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-link-primitive-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"07c5a9a6-23c9-4695-acbc-1e787f9c75ce","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-mobile-nav-polish","type":"changed","scope":"marketing","summary":"Mobile nav drawer gets the brand glyph header + close-icon rotate-on-hover + cta-lift on Get Started.","body":"The slide-in mobile navigation drawer (Radix Dialog) was bare —\ngeneric \"Menu\" title, no brand mark, default Get-Started button.\nMobile is half our traffic; the drawer deserves the same polish as\nthe desktop nav.\n\n- Title row now shows the brand glyph (custom logo URL or\n  `<HeliosMark>`) + `branding.appName`, so the drawer feels like\n  a continuation of the site rather than a generic dialog.\n- Close (`X`) icon rotates 90° on hover (200ms ease) — small\n  micro-interaction that makes \"close me\" feel deliberate.\n- \"Get started →\" button at the drawer bottom now carries\n  `cta-lift` — matches the desktop nav CTA.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-mobile-nav-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"177c11e4-d63d-412e-8d35-3ce4b530aaef","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-og-static-pages","type":"changed","scope":"marketing","summary":"Phase 14 — /changelog + /status get build-time OG cards instead of falling back to og-default.png.","body":"Before: `/changelog` and `/status` weren't registered with the OG-card\nendpoint (because they're hand-built pages, not CMS rows), so social\nshares fell back to `/og-default.png` — every link on Twitter / LinkedIn\nto either page showed the generic home OG card.\n\nAfter: the OG endpoint (`/og/[...slug].png.ts`) gains a `STATIC_PAGES`\nregistry — a small in-code list of pages without CMS rows that still\ndeserve their own branded OG card. `/changelog` and `/status` are\nseeded; future pages just append to the list.\n\n`changelog.astro` + `status.astro` now pass\n`ogImage=\"/og/<slug>.png\"` to PageLayout so the rendered HTML sets\nthe right `og:image` meta tag.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-og-static-pages.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"96a4323e-0cb6-41ca-aa33-6a1deab44185","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-page-imports-restore","type":"fixed","scope":"marketing","summary":"Restore all page-level Astro imports stripped by the Biome organizeImports sweep — build is green again.","body":"Companion to `marketing-layout-imports-restore`. Same root cause:\nthe repo-wide Biome `organizeImports` sweep mis-classified JSX-only\ncomponents in Astro frontmatter as \"unused\" and deleted them.\n\nThis pass restored 35 page-level `.astro` files. A scripted fix\nwalks every page, detects JSX usage of layout components +\nshared block/primitive/form/template components, and emits the\ncorrect named import (or barrel `@/components/blocks` import) at\nthe top of the frontmatter — careful to insert BEFORE any\nnon-import statement, so imports inside template-literal code\nsamples (e.g. the MCP sample on `/developers`) aren't mistaken\nfor real top-level imports.\n\nIt also converts `import type { X }` → `import { X }` for any\ncomponent used as a JSX element in the body.\n\nCoverage: 35 pages across `apps/marketing/src/pages/`, including\nthe homepage, pricing, developers, customers, about, compare/* (6),\nsolutions/* (6), integrations + dynamic [slug], product + dynamic\n[slug], careers, contact, status, trust, brand, blog index +\ndynamic [...slug], 404, 500, changelog, partners, ai, es/index,\npreview/[id], and pricing/calculator.\n\nPlus two manual fixes:\n- `changelog.astro` + `blog/index.astro` — `Pill` is in\n  `@/components/primitives/pill`, not `@/components/blocks`.\n- `integrations/[slug].astro` + `product/[slug].astro` —\n  deduplicate a `WebsitePageRenderer` import the script added on\n  top of an existing one.\n\nResult: marketing typecheck 0 errors across 167 files; full build\ngreen; all 53 pages indexed; all internal links resolve.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-page-imports-restore.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"5b27ef86-690f-4905-99a1-c3fb9b4880b3","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-partners-faqpage-schema","type":"changed","scope":"marketing","summary":"Phase 19 — emit FAQPage JSON-LD on /partners for rich SERP results.","body":"`/partners` ships an 8-item FAQ block via `<FAQ items={faqItems} />`\nbut didn't emit a `FAQPage` schema graph, so Google couldn't expand\nit as a rich result.\n\nThe page now composes `s.faqPage(faqItems)` into the existing\nbreadcrumb graph — the same `faqItems` array feeds both the\nrendered UI and the structured data, so they can never drift.\n\n`/pricing` already emits FAQPage; the home + everywhere else\neither has too-dynamic items (computed at React render time) or\nhasn't earned a FAQPage section yet.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-partners-faqpage-schema.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"1409d342-6722-48a5-a47f-1286313afad7","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-perf-hints","type":"performance","scope":"marketing","summary":"Phase 11 — font preload + Plausible preconnect + per-theme theme-color meta tags.","body":"- **Font preload** — `<link rel=\"preload\" as=\"font\">` for both Geist\n  Variable + Geist Mono Variable. Cuts ~50–80ms off LCP on cold loads\n  by starting the font fetch in parallel with HTML parse. The existing\n  `font-display: swap` rule still handles the unlikely flash-of-\n  unstyled-text gracefully if the preload races.\n- **Plausible preconnect + dns-prefetch** (production only, gated by\n  `PUBLIC_PLAUSIBLE_DOMAIN`) — kicks the DNS + TLS handshake off before\n  the analytics script downloads. ~100–150ms shaved on first-byte.\n- **theme-color per-scheme** — separate `<meta name=\"theme-color\">` tags\n  for `light` and `dark` color schemes. The browser chrome (mobile\n  status bar, Safari tab strip) now matches whichever theme the user\n  is in instead of always painting indigo.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-perf-hints.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"4ecf25ee-8526-4cc2-ac53-3c0a41fd00ab","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-prefers-contrast-more","type":"changed","scope":"marketing","summary":"Phase 16 — honor prefers-contrast: more for high-contrast OS settings.","body":"When a visitor's OS is set to \"Increase contrast\" (macOS) /\n\"High contrast\" (Windows), CSS reports `prefers-contrast: more`.\nThe site now responds in three ways:\n\n- **Border + muted text** darken: `--color-border` snaps to pure\n  black (light) / pure white (dark), `--color-muted` jumps from\n  the default gray to a much darker (or lighter, in dark mode)\n  value. Every component using these tokens sharpens automatically.\n- **Focus ring** thickens from `2px` to `3px` + offset `2px` → `3px`.\n  The `!important` overrides the per-component focus rules that ship\n  with shadcn-style components.\n- **Decorative radial back-glows** hide. The hero atmospheric blurs\n  are visual chrome — they reduce contrast for the text underneath,\n  so when the user has asked for max contrast, they go away.\n\nHonored on every page that uses the global stylesheet, including\nheroes, forms, cards, FAQ, prose pages, etc.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-prefers-contrast-more.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"347c9a8a-8ae5-4957-9a96-0d2064f34607","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-prefers-contrast","type":"changed","scope":"marketing","summary":"Phase 13 — honor `prefers-contrast: more` (lift muted + border tokens for low-vision users).","body":"Adds `@media (prefers-contrast: more)` token overrides that lift the\ntwo tokens which most affect readability for users with system-level\n\"Increase contrast\" enabled (Windows High-Contrast Mode, macOS\nAccessibility → Increase contrast):\n\n- **Light mode:** `--color-muted` 45% → 35% (~5.7:1 → 8.3:1 contrast on\n  white). `--color-border` 90% → 75% so card edges and dividers\n  clearly read as separators instead of fading into the background.\n- **Dark mode:** `--color-muted` 60% → 75% (~5.7:1 → 9.7:1 on near-black).\n  `--color-border` 15% → 30%.\n\nNothing else changes — the design language stays the same; only the\ntwo contrast-critical tokens shift. The cascading effect: every\nmuted-text-foreground and every border-tinted element across the\nentire marketing site gets sharper for users who explicitly opted\nin via OS-level setting.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-prefers-contrast.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"00217019-e742-43e4-93f9-c5a24cce74bc","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-pricing-blog-polish","type":"changed","scope":"marketing","summary":"Polish PricingPageTemplate hero + /blog index — atmosphere, billing-toggle shadow, blog cards lift.","body":"- **PricingPageTemplate hero** — gains primary radial atmosphere\n  matching every other page hero. Billing-cycle toggle pill picks up\n  a primary-tinted shadow on the wrapper; the active option carries\n  its own primary shadow so the \"Annual (save 20%)\" lands as a\n  deliberate selection, not a default neutral toggle. Pricing card\n  grid wraps in `.reveal` + `.reveal-stagger` so the 4 tiers cascade\n  in.\n- **/blog index** — hero gains primary radial atmosphere. Post list\n  upgraded from a plain divide-y list to `card-lift` cards\n  (per-row primary-tinted hover shadow + accent strip that opacity-\n  pulses + animated `→` glyph that slides + fades in on hover). Post\n  list wraps in `.reveal` with `.reveal-stagger` so cards cascade.\n  Dates now monospace, divider dots tint to `border` color.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-pricing-blog-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"83aedb1f-f33f-4bda-ae2e-32c272bd5d0c","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-product-hero-polish","type":"changed","scope":"marketing","summary":"Polish /product hero — primary radial atmosphere + dotted-grid texture + cta-lift CTAs.","body":"The `/product` page (the master \"14 modules\" index) hero was plain.\nNow matches the `/ai`, `/developers`, `/brand`, and home idioms:\n\n- Primary radial back-glow (16% opacity, ellipse 60×80 from top).\n- Dotted-grid texture (24px grid, radial mask) for the engineering-\n  page surface treatment.\n- Primary CTA: `cta-lift` + `data-magnetic`. Secondary CTA: `cta-lift`.\n\nEvery hero on the site now opens with the same brand atmosphere.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-product-hero-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"be526adf-9acf-4406-a280-dec711fc41da","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-scroll-anchors","type":"changed","scope":"marketing","summary":"Phase 18 — global scroll-margin on headings + smooth-scroll for anchor jumps.","body":"The sticky top nav is `h-16` (64px). When an anchor link landed on a\nheading inside the page (e.g. `/security#dangerous-actions`,\n`/changelog#R+88`), the heading was hidden behind the nav. The\nreader had to scroll up to find it.\n\nTwo CSS adds in `globals.css @layer base`:\n\n- `:where(h1, h2, h3, h4, h5, h6, section[id], article[id]) {\n  scroll-margin-top: 5rem; }` — pushes anchor jumps below the sticky\n  nav. Uses `:where()` so it has zero specificity and component-level\n  scroll-margin overrides (BlogLayout / LegalLayout already set this\n  per-prose-h2) still win.\n- `html { scroll-behavior: smooth; }` — anchor jumps now ease into\n  place instead of teleporting. The existing universal reduced-motion\n  reset (`scroll-behavior: auto !important`) handles the opt-out.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-scroll-anchors.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"89eaec37-b51b-43a9-b6e1-f88c3192180f","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-shared-blocks-polish","type":"changed","scope":"marketing","summary":"Elevate shared FeatureTile / Hero / Eyebrow / Stat — polish flows to every standalone page.","body":"Polish the shared block components that appear across every\nstandalone page (about, customers, brand, partners, ai, developers,\ncareers, status, trust, contact, solutions/*, compare/*). One fix\nelevates 10+ pages.\n\n- **FeatureTile** — was a plain bordered card. Now ships with\n  card-lift hover, a top-edge accent strip that opacity-pulses on\n  hover, a primary-tinted icon backplate that brightens on hover,\n  and on linked tiles a small `→` glyph that slides right on hover.\n  `<FeatureGrid stagger>` opt-in for reveal-stagger when nested in\n  a `.reveal` container.\n\n- **Hero** (generic, used on standalone pages) — primary CTA now\n  carries `cta-lift` + `data-magnetic`, secondary CTA carries\n  `cta-lift`. Mirrors the bento hero's interaction vocabulary on\n  every standalone page hero.\n\n- **Eyebrow** — gains a small leading accent rule (24px primary\n  hairline) that animates from `scaleX(0)` to `scaleX(1)` on parent\n  `.reveal` fire — mirroring the SectionLede pattern from the home,\n  so every page shares the same lede rhythm. `withoutRule` opt-out\n  for tight layouts (chip rows, badges).\n\n- **Stat** — adds `tabular-nums` so digits align in a row of\n  stats, plus a small primary accent dot prefixes the label so the\n  eye groups number + label visually. Optional `tone='primary'`\n  brand-tints the big number.\n\nAll effects honor `prefers-reduced-motion` via the shared utilities.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-shared-blocks-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"e65f3244-4cda-4feb-89dc-0d9a40acd3c1","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-status-subpages-polish","type":"changed","scope":"marketing","summary":"Phase 12 — polish /status/embed + /status/incidents/[slug] heroes.","body":"The two status sub-pages were the last public surfaces still missing\nthe brand-atmosphere hero treatment.\n\n- **`/status/embed`** — hero gains the primary radial back-glow other\n  page heroes carry. \"Status · Embed\" breadcrumb link uses\n  `.link-underline-grow`.\n- **`/status/incidents/[slug]`** — hero gains an incident-tone-aware\n  radial back-glow (whatever colour the incident's worst-status maps\n  to: amber for degraded, orange for partial outage, red for major\n  outage). Reads with the urgency the incident merits without being\n  alarming for resolved incidents.\n\nEvery public marketing page now opens with a tinted radial hero.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-status-subpages-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"f61df445-6c14-47a0-92d2-abfe9acf8e91","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-trust-contact-careers-polish","type":"changed","scope":"marketing","summary":"Polish /trust /contact /careers — atmospheric heroes, role-list card-lift, link-underline-grow.","body":"Three more standalone pages elevated to match the rest of the site.\n\n- **/trust** — hero gains primary radial atmosphere (matches other\n  page heroes that already carry brand weight). The 9-tile quick-links\n  grid now uses `<FeatureGrid stagger>` so the cards cascade in on\n  reveal. Security email link uses `link-underline-grow`.\n- **/contact** — every \"right inbox\" mailto link uses\n  `link-underline-grow`. The `<ContactForm>` panel gains a 2px primary\n  border + a primary radial back-glow + a soft primary-tinted shadow,\n  so the form panel reads as the page's call to action, not just a\n  panel.\n- **/careers** — hero gains primary radial atmosphere. Role-list cards\n  upgraded from plain border-hover to full `card-lift` treatment\n  (3px translate + brand-tinted shadow + opacity-pulse accent strip +\n  primary accent dot before the location + `→` glyph that slides\n  right on hover). Inline \"Tell us what you'd do\" link uses\n  `.link-underline-grow`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-trust-contact-careers-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"7f252c9c-e28a-417d-95be-cc921a2bf028","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-trust-logo-testimonial-polish","type":"changed","scope":"marketing","summary":"Polish TestimonialQuote / LogoStrip / TrustBadge shared blocks.","body":"Continuing the shared-block elevation pass. Three more components\nthat appear across many pages:\n\n- **TestimonialQuote** — gains a hanging open-quote glyph in\n  primary/25 (NYT-magazine editorial), a soft primary radial\n  atmosphere behind the card, card-lift hover with brand-tinted\n  shadow, and the avatar disc now carries a primary ring-2/20 outer\n  glow. Design-partner mode also surfaces a small \"private\" pill\n  next to the label.\n- **LogoStrip** — logo images now hover-scale to 1.06 + lose\n  grayscale (instead of just opacity), 300ms ease. Text-only \"logos\"\n  hover-transition to full fg color.\n- **TrustBadge** — adds a small green check glyph before the label\n  (opt-out via `checked={false}` for in-progress badges). Hover lift\n  of 1px + soft primary-tinted shadow + primary/[0.04] background.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-trust-logo-testimonial-polish.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"42a1bd9c-109e-4b6b-bb93-98e2fd82d457","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"marketing-view-transition-persist","type":"changed","scope":"marketing","summary":"Phase 15 — persist the TopNav brand link across Astro View Transitions.","body":"Astro's `<ClientRouter />` already animates same-origin nav with a\nfade swap, but every element in the new page was a fresh mount.\n\n`data-astro-transition-persist=\"brand-link\"` on the TopNav home link\nkeeps that DOM node alive across the swap — the brand glyph + word-\nmark no longer flicker out and back in on every navigation, which\nmakes header-anchored animations (like the brand mark's hover state\nor any future hero/avatar morph) read as continuous rather than\nre-mounted.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-24-marketing-view-transition-persist.md","internalOnly":false,"createdAt":"2026-06-04T01:10:01.120Z","updatedAt":"2026-06-04T01:10:01.120Z"},{"id":"1a7bd8f9-23ad-4035-924c-84f053b5e66f","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-48-a3-cross-project-duplicate","type":"added","scope":"projects","summary":"Task row menu gains \"Duplicate to project…\" — pick a target project and the task is copied there with a fresh number label from that team.","body":"R88.48 A-3 — Cross-project duplicate. Queued as a follow-up from\nR88.48 A (row context menu shipped 873259bb) where \"Duplicate\" was\nsame-project only.\n\n## What ships\n\n**Server** — `projects.task.duplicate` action accepts an optional\n`targetProjectId`. When set (and different from source's project):\n\n- Target project is looked up (must be in the same org)\n- Number label is generated from the TARGET team's prefix (e.g. move\n  `ENG-42` from Engineering → Ops → new task becomes `OPS-14`)\n- `sectionId` is dropped (source's section doesn't exist in target)\n- `cycleId` is dropped (cycles are team-scoped per ADR 0011 D17)\n- Everything else (title with `(copy)` suffix, description, priority,\n  assignee, estimates, milestone flag) carries across\n\n**Client** — new \"Duplicate to project…\" menu item on `TaskRowMenu`.\nOpens a small Modal with a Picker over the org's active projects\n(excludes source's own project + archived/completed/canceled).\nSelecting + confirming fires the mutation.\n\n## Design decisions\n\n- **New menu item, not a sub-menu.** Radix DropdownMenu's sub-menu\n  primitive is designed for nested option lists, not for opening\n  dialogs. Two sibling menu items (`Duplicate` and `Duplicate to\n  project…`) is clearer + keyboard-friendlier.\n- **Modal, not inline picker.** Cross-project is a heavier decision\n  than same-project — worth a confirm step. Modal shape also lets\n  us show a hint about the number label / section reset if\n  operators surface confusion.\n- **Filter excludes archived / completed / canceled projects.**\n  Duplicating INTO a done project doesn't match any real workflow.\n- **Filter excludes source's own project.** Would just be a\n  same-project duplicate; the `Duplicate` item already handles that.\n- **`section = null`, `cycle = null` on cross-project.** No safe\n  auto-mapping across projects (section names differ; cycles are\n  team-scoped). Operator can attach after the fact if needed.\n- **Number label uses target's team prefix.** Matches ADR 0011 D14\n  — every task's number label is `${team.taskPrefix}-${number}`, so\n  a task in the OPS project reads as `OPS-14`.\n\n## Tests\n\n5/5 existing `task-duplicate.test.ts` tests continue to pass — they\ndon't pass `targetProjectId`, so the handler falls through the\nexisting same-project code path.\n\n## What's queued\n\n- **Move (not copy)** — same targeting UX but MOVES the source task.\n  Would need to preserve the source's number label OR issue a new\n  one; ADR question. Held until operator ask.\n- **Multi-select duplicate** — bulk bar already supports multi-\n  select on the board; wiring bulk-duplicate-to-project would need\n  the bulk action extended. Held for R88.48 A-4.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.475Z","updatedAt":"2026-07-14T02:28:40.475Z"},{"id":"8d575486-8c3a-4f4c-9613-56978f9901d9","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-48-c-hotfix-tasksquery","type":"fixed","scope":"projects","summary":"Fix \"tasksQuery is not defined\" runtime error introduced by R88.48 C — the variable in the project detail page is `tasks`, not `tasksQuery`.","body":"R88.48 C introduced a `ReferenceError: tasksQuery is not defined`\nruntime crash on the project detail page. The count-pill wiring\nreferenced `tasksQuery.isLoading` — but the query variable in the\nroute file is called `tasks` (line 754 of `$projectId.index.tsx`).\n\nTypecheck missed this because I never opened the parent file before\nauthoring the callsite; the name looked plausible from the queryKey\nconvention but doesn't match the local binding.\n\n## Fix\n\nOne-line change at the `<ProjectTabBar activeCount={...}>` callsite:\n\n```\n-  activeCount={tasksQuery.isLoading ? undefined : taskItems.length}\n+  activeCount={tasks.isLoading ? undefined : taskItems.length}\n```\n\n## Follow-up discipline\n\n- Before wiring a new prop, `grep -n \"useQuery\\|tasksQuery\" <parent>`\n  and confirm the binding name matches what I'm referencing.\n- Typecheck in the parent module (not just the child component)\n  catches these. R88.48 C's typecheck was scoped to\n  `apps/web/src/components/projects/project-tab-bar.tsx` — the\n  callsite in `routes/projects/$projectId.index.tsx` never\n  compiled clean because I moved on before the parent typecheck\n  caught up.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.555Z","updatedAt":"2026-07-14T02:28:40.555Z"},{"id":"d89a71a8-3bb9-41ee-a6ad-02e0e5f177d0","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-48-c-tab-count-badge","type":"added","scope":"projects","summary":"Project tab bar shows a small count pill next to the active view's label — \"List 42\" — so operators see how many tasks the current filter set surfaces without scanning.","body":"R88.48 C — Count pill on the active view tab. Inspired by Plane's\n`Work items 112` sub-header count. Ours is the same information at\nthe tab-level so the header row stays consolidated.\n\n## What ships\n\n- New `activeCount?: number | null` prop on `<ProjectTabBar>`.\n- When the parent passes a numeric count and the tab is the active\n  view (List / Board / Grouped / Calendar / Timeline), a small\n  rounded-pill chip appears next to the label:\n  `[📋 List 42]`\n- Non-active view tabs stay count-free — the tab strip doesn't turn\n  into a wall of numbers.\n- Loading state (`tasksQuery.isLoading === true`) suppresses the pill\n  so the count doesn't flash `0` while data flies in.\n\n## Design\n\n- **Pill uses `tabular-nums`** so counts of different widths\n  (`1` → `12` → `112`) don't shift the tab layout on filter changes\n- **`key={count}` replays an entrance animation** on every count\n  change (`animate-in zoom-in-75`) so operators see the pill react\n  to their filter edits instead of silently ticking\n- **`bg-emphasis` + `fg-muted`** — same recipe as the count chips in\n  board / grouped headers for visual continuity across the module\n- **Positioned in the `TabContent` render**, not as an overlay —\n  keeps the tab's own click/hover states intact and lets the pill\n  flow naturally next to the label\n\n## What isn't included\n\n- **Loading skeleton for the pill.** Currently we hide it during\n  loading; a shimmer strip would be more polished but the flicker\n  window is short enough that hiding reads cleaner\n- **Count for non-active tabs.** Would require the parent to know\n  the total for every view type simultaneously — same data actually\n  (they all read the same filtered list) but shows 5 identical\n  numbers on the tab strip. Held pending signal.\n- **Count for surface tabs (Data / Files / Overview / Settings).**\n  Different semantic — those aren't \"task counts\" and putting\n  numbers on all of them would dilute the signal","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-07-14T02:28:40.650Z","updatedAt":"2026-07-14T02:28:40.650Z"},{"id":"7926f2a9-29ab-49e7-8b5a-f5420c1577eb","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"smtp-test-actionable-errors-and-longer-timeout","type":"fixed","scope":"email","summary":"SMTP test surfaces an actionable error (DNS / refused / unreachable / wrong port / cert) and the timeout floor moves from 10 s → 30 s default, max 120 s.","body":"Reported on the staging deploy: `email.platform_provider.test` failed\nwith the unhelpful \"Connection failed — SMTP socket timeout\" even when\nthe host + port + creds were correct. Two real problems converged.\n\n## What was wrong\n\n1. **Default timeout was 10 s; cap was 30 s.** Office 365 / Gmail /\n   Mailgun routinely take 15–25 s during TLS handshake under load —\n   the previous default fired spuriously on perfectly healthy\n   production relays. Slow staging environments behind a strict\n   egress firewall sometimes need 60 s+. The 30 s ceiling left no\n   headroom even for admins who knew what to dial up.\n2. **The error message was a black box.** \"SMTP socket timeout\" gave\n   the operator no clue whether DNS failed, the firewall dropped\n   packets, the port was wrong, or the server itself was slow.\n   Diagnosing required SSHing into staging and tcpdumping.\n\n## What changed\n\n  - **Schema:** `timeoutMs` default raised from 10 s to **30 s**;\n    cap raised from 30 s to **120 s**.\n  - **Form:** `timeoutMs` is now a real field in the SMTP admin form\n    with help-text suggesting the 60–90 s range for slow networks.\n  - **Phase-aware errors:** every `SmtpConnectError` carries a\n    `phase` (`connect` / `handshake` / `read`) so the failure message\n    is specific. Three example messages the test now produces:\n    - *\"No TCP response from smtp.gmail.com:587 within 30000ms —\n       check host/port and outbound-firewall rules\"*\n    - *\"Connected to smtp.office365.com:587 but the server didn't\n       send a banner within 30000ms — wrong port (try 587/465/25)\n       or server is overloaded\"*\n    - *\"Read timed out mid-conversation with smtp.mailgun.org:587\n       after 30000ms — server stalled; raise the timeout or check\n       provider status\"*\n  - **OS-level errors translated:** ENOTFOUND / ECONNREFUSED /\n    EHOSTUNREACH / ETIMEDOUT / ECONNRESET / EPIPE / TLS-cert errors\n    each get a specific, action-oriented message instead of the\n    bare libuv string.\n\nThe send-side error mapping in `mapSmtpError` is unchanged — the\nretry classifier still routes connect failures to\n`transient_network` with `retryable: true`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-16T13:46:00.851Z","updatedAt":"2026-06-16T13:46:00.851Z"},{"id":"1e4594e0-1b0a-46f2-a80f-6bd77ddd2035","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-39-task-code-abbreviation","type":"fixed","scope":"projects","summary":"Long task codes (e.g. RESTORATION-PROS-LA-198) now render abbreviated to initials per segment (RPL-198) in list / board / calendar / gantt / dashboards, so multi-line wrapping in narrow columns is gone. Full code stays available on title hover.","body":"R88.39 — User-reported fix for broken layout in list view when a\nproject slug has many hyphenated segments. \"RESTORATION-PROS-LA-198\"\nwrapped onto three lines in the 56px task-code column because the\nbrowser breaks on hyphens.\n\n## New helper\n\n`apps/web/src/components/widgets/task-views/task-code-format.ts`\nexposes `abbreviateTaskCode(numberLabel: string): string`:\n\n- Splits on the LAST `-` → `prefix` + `number`.\n- Multi-segment prefix → first character of each segment joined.\n- Single-segment prefixes left as-is (`WEBSITE-42` is already\n  fine).\n- Short segments that contain a digit are kept whole, so\n  meaningful identifiers like `Q2` or `R3D` aren't stripped.\n\nExamples:\n\n| Original | Abbreviated |\n|---|---|\n| `RESTORATION-PROS-LA-198` | `RPL-198` |\n| `Q2-PLATFORM-HARDENING-1` | `Q2PH-1` |\n| `R3D-PRINT-1` | `R3DP-1` |\n| `WEBSITE-42` | `WEBSITE-42` (unchanged) |\n| `ENG-198` | `ENG-198` (unchanged) |\n\nThe DB still stores the long form; this is render-only. Every\napplied span keeps the original on `title=` so users who hand-type\nthe full code in search or URLs can still copy it from the tooltip.\n\n## Surfaces fixed\n\n| Surface | File |\n|---|---|\n| Project detail page list view | `$projectId.index.tsx` |\n| Cross-project tasks list | `projects/tasks.tsx` |\n| My tasks | `projects/me.tsx` |\n| Board card header | `board-view.tsx` |\n| Grouped view rows | `grouped-view.tsx` |\n| Calendar overflow pills + day-cell chips | `calendar-view.tsx` |\n| Gantt overflow pills + row labels | `gantt-view.tsx` |\n| Task detail sheet subtasks | `task-detail-sheet.tsx` |\n| Risk table | `projects/analytics.tsx` |\n| Cycle backlog + drag tile | `cycles.$cycleId.tsx` |\n| Manager / employee / calendar dashboard task lists | `dashboard/*.tsx` |\n\n## What's NOT changed\n\n- DB stored task codes — historical references in audit logs,\n  emails, URLs, etc. all still work.\n- The single-segment short-code (e.g. `WEBSITE-42`) — already\n  fine, left as-is by the helper's guard.\n- aria-labels — they still include the full code so screen\n  readers + copy-from-aria still get the long form.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-15T23:11:31.133Z","updatedAt":"2026-06-15T23:11:31.133Z"},{"id":"3b1a5f69-395a-4192-98bd-c4908dbbabc6","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"sales-emails-respect-org-branding","type":"fixed","scope":"sales","summary":"Sales emails (invoice / quotation / payment / credit-note) now respect per-org brand colour + name, not just platform branding.","body":"Sales-side inline-HTML email composers were reading `platform_settings`\nonly, so per-org brand colours and names were silently dropped from\ninvoice / quotation / payment / credit-note emails — even though the\nPDFs attached to those same emails picked up the org branding correctly.\n\nA new shared `resolveSalesBrand(db, orgId)` helper in\n`modules/sales/src/lib/email-format.ts` does the canonical cascade:\n**organisation row → platform row → defaults** — matching the email\nmodule's `resolveBrandingVars` priority. Drops legacy `'Helios'` +\n`@helios/worker`-style npm-scope sentinels so a deployment that still\nruns on the seed brand doesn't leak it to customer emails.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T16:06:58.406Z","updatedAt":"2026-06-06T16:06:58.406Z"},{"id":"86020db8-2255-4d13-83c2-b3f521d82be2","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-33-header-tile-overflow-meta-tighten","type":"fixed","scope":"projects","summary":"Project header — icon tile now slices multi-character text to 2 graphemes (was rendering \"git-branch\" raw and overflowing); slug dropped from the meta line; client/engagement chips fold into the meta row.","body":"R88.33 — Three header polish fixes after a user screenshot\nshowed multi-char icons overflowing the tile + the meta line\nsplitting the project context across multiple rows.\n\n## Phase 1 — Tile icon overflow\n\nThe `projects.icon` column accepts up to 64 chars at the schema\nlayer and up to 8 at the create-sheet input. Users were setting\nit to multi-character labels (\"git-branch\", \"shield\") which then\noverflowed the 32px tile and even wrapped on hyphens, producing\na two-line tile that broke the header rhythm.\n\n`<ProjectHeaderTile>` now slices the icon down to its first 2\ngrapheme clusters (emoji-safe — a single emoji stays one\ngrapheme), then uppercases any ASCII letters so the result\nreads as a tile label rather than a mid-word truncation.\n\"git-branch\" → \"GI\", \"shield\" → \"SH\", \"🚀\" → \"🚀\".\n\nBelt-and-braces: the tile span also adds `overflow-hidden\nwhitespace-nowrap` so even if a future code path leaks a long\nstring in there, the box won't grow or wrap.\n\n## Phase 2 — Slug dropped from header meta\n\nThe header meta line read `Team · PROD · target 2026-08-02 ·\naudit-log-partitioning-workstream`. The trailing slug was\n20-40 mono-font chars that ate horizontal space without\nadding info the user can't see in the URL bar.\n\nNow: `Team · PROD · target 2026-08-02`. The Settings page is\nstill the place to view / edit the slug.\n\n## Phase 3 — Client/engagement chips inline with meta\n\nThe meta line and the client/engagement chips were rendered\nin two separate stacked rows under the title. Merged into a\nsingle wrap row so on wide viewports the chips sit inline with\nthe team/target text, saving a row of vertical space; on\nnarrow viewports the chips wrap to a second row naturally.\n\nNet effect: the header is 1 row shorter on every project (when\na client is set), and the icon tile no longer breaks visual\nrhythm when users set multi-char icon labels.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:37.863Z","updatedAt":"2026-06-13T16:40:37.863Z"},{"id":"ff59b1c0-fedd-4e1f-b273-1482d7e6cc6e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"email-chrome-polish-and-weekly-digests","type":"added","scope":"email","summary":"Modern email chrome (better type, spacing, metric tiles) + weekly digest templates for CRM, Sales, Projects, Recruitment.","body":"Two improvements to the email subsystem:\n\n## Modern email chrome\n\n`baseLayout()` polished — larger root font (15px), tighter letter-spacing on\nheadings, a roomier 600px width, modern card aesthetic with subtle shadow\ninstead of a hard border, sharper button corners, and a refined neutral\ncolour palette across heading / body / muted tones. Two new helpers added:\n\n- `metricTile({ label, value, delta?, direction? })` — a small data-card\n  used by the digest layouts. Direction (`up` / `down` / null) drives the\n  delta colour.\n- `divider()` — visual break between sections inside the card.\n\nPlus new utility classes baked into the chrome: `.pill-success`,\n`.pill-warn`, `.pill-danger` for status badges, and `.data-table` for\nclean digest tables that render across Outlook + Gmail + Apple Mail.\n\n## Weekly digest templates\n\nFour new templates + four new flow registrations covering the\nhigh-value digest surfaces:\n\n- `crm.weekly_digest` — wins / losses / new leads / pipeline value,\n  top performers, stalled deals.\n- `sales.weekly_digest` — invoiced / collected / overdue, AR aging,\n  top-5 overdue invoices that need chasing.\n- `projects.weekly_digest` — completed / created / overdue tasks,\n  shipped projects, at-risk projects.\n- `recruitment.weekly_digest` — applicants / interviews / offers /\n  hires, longest-open roles, stalled applications.\n\nAll four use the new chrome's 4-up metric grid + `.data-table` rows.\nThey expect the cron-side aggregator to pre-render the table rows as\n`*Html` / `*Text` template variables so the templates stay pure\npresentation.\n\nWiring (worker crons + subscribers + fan-out to org owners + module\nmanagers) is the follow-up commit; the templates + flows are first so\nthe visual design can be validated independently.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:15.952Z","updatedAt":"2026-06-06T17:00:15.952Z"},{"id":"d010330a-1e69-48b9-91e8-5bcb1aa781ae","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"sales-statement-refund-org-branding-and-chat-ai-sentinel","type":"fixed","scope":"sales","summary":"Statement + refund-receipt emails now respect per-org brand; chat AI assistant no longer introduces itself as \"Helios\".","body":"Follow-up to the 2026-05-19 sales-email branding fix. Two more inline-HTML\ncomposers (`statement-email`, `refund-email`) were reading\n`platform_settings` only, so per-org brand colour + name + letterhead +\nlegal address silently disappeared from those customer-facing emails.\n\nThe shared `resolveSalesBrand(db, orgId)` helper was extended with the\nextra brand fields the statement PDF needs:\n\n- `logoUrl` — now cascades **letterhead → org logo → platform logo**\n  (matching the PDF generator's `coalesce(o.letterhead_logo_url, o.logo,\n  p.logo_url)` SQL, so the same image lands on both the printed page\n  and the surrounding email)\n- `marketingUrl` (platform-only)\n- `companyAddress` — org address → platform company address\n- `companyTaxId` — org tax id → platform company tax id\n- `orgName` — distinct from `appName` for callers that want the org's\n  display name specifically (e.g. \"Statement of account for Acme\")\n\nPlus a fix for the chat AI mention prompt: `buildSystemPrompt` was\nintroducing the assistant as \"I am Helios\" on every freshly-seeded\ndeployment because it didn't filter the same sentinels (`'Helios'`\n+ npm-scope `@scope/pkg` patterns) that every other surface drops.\nMatching sanitization now lives there too.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-06T17:00:15.928Z","updatedAt":"2026-06-06T17:00:15.928Z"},{"id":"d4378329-6785-4ab8-8f84-24fd407e4d33","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-34-filter-chip-polish","type":"changed","scope":"projects","summary":"Filter chips and the dock funnel trigger pick up the standard tactile vocabulary — hover/active scale, focus ring, caret rotation on open, h-6 height alignment.","body":"R88.34 — Polish round for the projects-module filters UI. The\naudit found the ChipMenu was missing the tactile feedback the\nrest of the module ships (quick-filters, active-filter-pills,\nfilter-builder \"Add filter\"), and the funnel trigger in the\nsearch-filter-dock was bare-bones too.\n\n## ChipMenu (the active condition chips)\n\n- **Tactile** — Label-trigger now scales 1.04x on hover + 0.95x\n  on active press; close × button scales 1.15x on hover + 0.95x\n  on press. Matches the active-filter-pills and quick-filter\n  chip vocabulary.\n- **Caret rotation** — `<CaretDown>` rotates 180° when the menu\n  opens (driven by Radix's `onOpenChange`), so the affordance\n  reads as toggle rather than inert.\n- **Border tint on open** — The chip's border softly tints\n  toward accent-default when the menu opens, visually linking\n  the chip to its open menu below.\n- **Height** — `h-6` fixed so the chip sits on the same 24px\n  row baseline as quick-filters + active-filter-pills (was\n  variable from `py-0.5` padding).\n- **Focus-visible rings** — Inset accent ring on the label\n  trigger; inset danger ring on the × button. Keyboard nav\n  now has a clear target inside the chip pill.\n\n## SearchFilterDock funnel trigger\n\n- **Tactile** — Funnel icon scales 1.1x on hover + 0.95x on\n  press; subtle hover bg tint when no filters are active so\n  the affordance reads as a clickable target.\n- **Focus-visible ring** — Matches the rest of the module's\n  chip vocabulary.\n\nThe active-filter-pills, quick-filters, and filter-builder\n\"Add filter\" CTA were already polished in R88.21 and aren't\ntouched here.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T16:40:38.604Z","updatedAt":"2026-06-13T16:40:38.604Z"},{"id":"779c0138-ea60-41b7-86a2-4b8828713617","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-35-image-upload-button","type":"added","scope":"projects","summary":"Project identity image now has a real upload button alongside the URL-paste field, in both the create sheet and the settings page.","body":"R88.35 — User-facing upload UI for the project identity image.\nPreviously the only way to set a project's image was to paste a\npublic URL; now an Upload button sits alongside the URL input\nin both the New Project sheet (image mode) and the Settings\nidentity card (image mode).\n\n## How it works\n\n`<ProjectImageUploader>` is a small standalone component that\nmirrors `<FormImagePicker>`'s two-leg flow but without the\nTanStack Form wrapper (the create sheet + settings page use\nplain React state, not TanStack Form):\n\n1. The browser POSTs `{ filename, contentType, sizeBytes,\n   scope: 'organization' }` to `platform.asset.upload_url`.\n   The server presigns a 5-minute PUT and returns a stable\n   proxy URL (`/api/files/...`).\n2. The browser PUTs the file bytes directly to the presigned\n   URL.\n3. The caller stores the stable proxy URL in `projects.imageUrl`.\n   The proxy re-presigns a GET on every read, so the DB never\n   holds an expiring signed URL.\n\n## Guards\n\n- Allowed content types: `image/png`, `image/jpeg`, `image/webp`,\n  `image/gif`, `image/svg+xml` (same as `<FormImagePicker>`).\n- Max file size: 5 MiB (matches `<FormImagePicker>` — generous\n  for project tile images but small enough to PUT before the\n  5-min presigned URL expires on slow uplinks).\n- Errors render inline below the upload button + propagate to\n  the caller via `onError` for toast-level surfacing.\n\n## URL paste field stays\n\nUsers who already host their image elsewhere (CDN, design tool\npreview URLs, etc.) can still paste a URL — the paste input is\nunchanged. The upload button is additive.\n\n## Tactile chrome\n\nThe Upload button follows the module-wide chip vocabulary:\nhover scale-[1.03], active scale-95, disabled scale-100,\nfocus-visible accent ring, busy spinner.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:22.863Z","updatedAt":"2026-06-13T19:04:22.863Z"},{"id":"a8e839aa-8a30-466f-b746-a5ffce104efd","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-37-toolbar-controls-polish","type":"changed","scope":"projects","summary":"DensityToggle, SortPicker direction button, and SavedViewPicker caret pick up the standard tactile vocabulary — hover scale, focus rings, caret rotation on open.","body":"R88.37 — Polish round for the toolbar control trio (Density,\nSort, Saved view) that sits one row below the project header's\nCustomize button (R88.36).\n\n## Phase 1 — DensityToggle\n\nWas a bare segmented control with `transition-colors` only. Now:\n\n- Active button bumps to `bg-default` with `accent-default` text\n  + a soft 1px shadow so the selected density reads confidently.\n- Both buttons gain hover scale-[1.08] + active scale-90 +\n  focus-visible accent ring.\n- Group container gets a subtle `bg-subtle/40` fill so the\n  segmented control reads as a unit.\n\n## Phase 2 — SortPicker direction toggle\n\nThe ascending/descending toggle button gets:\n\n- Hover scale-[1.06] + active scale-95 + focus-visible accent\n  ring (was hover-color only).\n- The Sort{Asc,Desc}ending glyph picks up a 300ms transform\n  transition so when the user clicks to flip direction, the\n  swap visually telegraphs rather than blinking.\n\n## Phase 3 — SavedViewPicker\n\nThe CaretDown on the trigger now rotates 180° when the menu\nopens (matching the ChipMenu polish from R88.34). The Menu\nprimitive's `onOpenChange` mirrors into a local `menuOpen`\nstate since the asChild trigger doesn't expose state directly.\n\n## Skipped\n\nGroupByPicker — already uses the `<Picker>` primitive cleanly;\nits tactile feedback comes from there. No surface-level polish\nneeded.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:23.605Z","updatedAt":"2026-06-13T19:04:23.605Z"},{"id":"c6a20859-3543-43c5-a646-dcd98d6f09fb","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-36-inline-add-board-customize-polish","type":"changed","scope":"projects","summary":"Three high-frequency surfaces pick up the module-wide tactile vocabulary — the inline \"+ Add task\" row, the board column header buttons, and the project tab bar's Customize / Shortcuts buttons.","body":"R88.36 — Polish round for high-frequency clickable surfaces in\nthe projects module that hadn't picked up the standard tactile\nvocabulary from earlier rounds.\n\n## Phase 1 — InlineAddTaskRow\n\n- The collapsed \"+ Add task\" button: tone shifts to\n  `accent-default` text on hover (was `fg-default`), Plus icon\n  scales 1.18x on hover/focus, focus-visible inset accent ring\n  for keyboard targeting.\n- The expanded inline editor: gains a 2px `accent-default` left\n  rail + soft accent-tinted background so the user can see at a\n  glance which row is \"live\" versus the resting + Add task rows\n  above or below.\n\n## Phase 2 — Board column header\n\n- Collapse-toggle button gets a focus-visible accent ring (was\n  hover-only).\n- \"+ New task in column\" button picks up the standard tactile\n  vocabulary: hover scale-110, active scale-95, focus-visible\n  accent ring. Reads as a primary affordance now, not a dead\n  glyph in the corner.\n\n## Phase 3 — Project tab bar toolbar\n\n- Customize button: hover scale-[1.04], active scale-95,\n  focus-visible accent ring. The Sliders glyph rotates -12°\n  on hover so the affordance reads as \"twiddle these settings\".\n- Shortcuts (?) button: hover scale-[1.06], Question glyph\n  scales 1.1x on hover, focus-visible accent ring.\n\n## What was skipped\n\nNewTaskQuickPrompt — already polished in earlier rounds\n(R88.7 / R88.8 added the title input border animation +\nsliding accent underline + character counter).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:23.607Z","updatedAt":"2026-06-13T19:04:23.607Z"},{"id":"4f13dc16-ce30-4b45-b3e4-d6d3e4197275","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-38-view-switcher-bulk-bar-checkbox","type":"changed","scope":"projects","summary":"ViewSwitcher tabs, board bulk-action bar buttons, and the task select checkbox pick up the module-wide tactile vocabulary.","body":"R88.38 — Polish round for three more high-frequency clickable\nsurfaces in the projects module.\n\n## Phase 1 — ViewSwitcher\n\nThe List / Board / Grouped / Calendar / Gantt tab strip now:\n\n- Active tab gains a soft 1px shadow lifting it off the\n  segmented bg.\n- All tabs gain hover scale-[1.04] + active scale-95 +\n  focus-visible accent ring.\n- Tab icon scales 1.1x on hover so the affordance is crisp.\n\n## Phase 2 — Board bulk-action bar\n\n`BulkButton` had imperative `onMouseOver` / `onMouseOut` /\n`onBlur` handlers flipping the background color via inline\nstyle mutations — a leftover from before the module standardized\non Tailwind hover variants. Replaced with CSS-driven hover +\nfocus-visible states + tactile scale (hover scale-[1.05] +\nactive scale-95). Danger variant gets a tinted danger ring.\nCleaner, lower-jank, matches the module's chip vocabulary.\n\n## Phase 3 — TaskSelectCheckbox\n\nThe per-row tri-state checkbox now:\n\n- Hover scale-110 + active scale-90 so the box feels physical.\n- Empty state gains a soft 6% accent-default tint on hover so\n  the user previews the \"I'm about to check this\" state.\n- Focus-visible ring with ring-offset (the box is small enough\n  that a non-offset ring would clash with its border).\n\nThe filled state's accent-default shadow halo stays as the\n\"this is checked\" cue.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T19:04:23.864Z","updatedAt":"2026-06-13T19:04:23.864Z"},{"id":"35f3de4e-fd20-4879-b515-dae122377b1d","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-activity-log-error-fanout","type":"fixed","scope":"projects","summary":"Fixed activity-log error spam under projects.* (subtask list, bulk assignee, activity feed) and shrunk the oversized project status pill on the detail page.","body":"Four production-log fixes diagnosed from the activity-log error\nfan-out:\n\n- `projects.task.list_children` no longer fails on every Subtasks\n  panel open — the task detail sheet was sending `taskId` instead\n  of the action's required `parentTaskId`.\n- `projects.task.set_assignee` bulk + inline editor calls no\n  longer fail validation — three stragglers were sending the\n  legacy `assigneeId` field instead of the action's\n  `assigneeUserId`.\n- `projects.task.list_activity` no longer 500s under load — the\n  raw `sql\\`= ANY()\\`` action-name filter has been switched to the\n  canonical `inArray()` helper, and the audit_log read is now\n  wrapped so any future DB-level exception surfaces as a typed\n  retryable error instead of an unhandled 500.\n- The project detail page header's status pill (the\n  `▶ Active` rounded dropdown) no longer stretches across the\n  full header row. The picker primitive's default `w-full` was\n  filling all remaining row width because the title `h1` had\n  `truncate` but no `flex-1 min-w-0`. Capped the picker at a\n  sensible max width and gave the title the right flex\n  constraints so long names truncate cleanly.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:32.761Z","updatedAt":"2026-06-07T04:26:32.761Z"},{"id":"4b8577ef-57b2-43d2-ac7a-222fa2303082","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-21-overlay-task-id-filters-polish","type":"changed","scope":"projects","summary":"Polished sidebar hover-overlay (frosted glass + micro-anim), fixed task ID column wrap, restored slug-collision check, polished filter pills + chips.","body":"R88.21 — five polish phases across the projects module from a\nback-to-back queue of user feedback during the session.\n\n1. NewProjectSheet now does a client-side slug-collision pre-check\n   against the existing project list and blocks Create + shows\n   an inline warning under the URL row when the effective slug\n   would conflict. The server still authoritatively rejects, but\n   the user gets the feedback immediately tied to the URL row\n   instead of bouncing through a toast.\n\n2. Project sidebar hover-action overlay. The action cluster\n   (add-child / edit / archive / favorite + team prefix) was an\n   in-flow flex child whose buttons only became visible on\n   hover via opacity-0, but the row still allocated their layout\n   space — every project name truncated even when no actions\n   were visible. Now an absolute-positioned floating overlay\n   with frosted-glass backdrop (`backdrop-blur-md` +\n   `backdrop-saturate-150`), elevated shadow, a slide-and-fade\n   reveal animation on a spring-soft easing, and per-button\n   scale/press micro-interactions. The always-visible \"starred\"\n   indicator was split into a tiny inline ★ next to the project\n   name so the row at rest still telegraphs favorite state.\n\n3. Task ID column layout. In the grouped list view + gantt view,\n   the task code column was constrained to `w-16` / `w-12`\n   (48-64px), which is narrower than every realistic team\n   prefix. The code (e.g. `Q2-PLATFORM-HARDENING-1`) wrapped on\n   every hyphen, rendering three short lines per row. Now sizes\n   to content with a sensible min/max and `whitespace-nowrap +\n   truncate` so the column stays aligned across rows but\n   ellipsizes pathologically long codes (full code still in the\n   title tooltip).\n\n4. Filter pills + quick-filter chips now slide+fade in on mount,\n   gain a subtle 1.04x hover scale, and the × button gets a\n   1.15x hover scale + active scale-95 press cue. Adds proper\n   focus rings on the Clear All / Clear / chip buttons for\n   keyboard navigation.\n\n5. Filter-builder \"Add filter\" CTA. Was a dashed-border ghost\n   button that read as inert; now subtly leans into the accent\n   palette on hover, ships with a focus ring + tactile scale\n   so it reads as the primary affordance.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T04:26:33.458Z","updatedAt":"2026-06-07T04:26:33.458Z"},{"id":"9add2288-de6d-4d6c-88fd-8acdfc111ddc","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-22-creation-overhaul","type":"changed","scope":"projects","summary":"Overhauled project creation with three configurable identity modes (initials / emoji / image), client auto-select, and a redesigned hero layout.","body":"R88.22 — Project creation flow + identity system overhaul.\n\n## New: Project image identity\n\nProjects now support a third identity mode alongside emoji icons and\nauto-initials: an image URL. Paste a public CDN URL (S3, Cloudinary,\nImgix) and the project tile renders the image in the sidebar, page\nheader, and every task chip. The image takes precedence over icon\nand initials; clearing it falls back through the existing tile.\n\nStorage: a new `image_url` column on `projects_projects` (migration\n`0244_0245_projects_image_url`). Both `projects.project.create` and\n`projects.project.update` accept an optional `imageUrl: string`\nvalidated as a URL up to 2000 chars. Pass `imageUrl: null` to clear.\n\n## Redesigned New Project sheet\n\n- **Hero identity block.** An 80px live-preview tile sits next to the\n  name + URL, with a three-way segmented control (Initials / Emoji /\n  Image) below. Each mode reveals its own picker content:\n  - Initials: optional override + color picker\n  - Emoji: preset gallery + freeform input + color picker\n  - Image: URL input with inline validation + live preview\n  Switching modes preserves each mode's prior input — toggle to\n  compare without losing work.\n- **Section cards.** Identity / Where it lives / Description now sit\n  in bordered cards with proper headers instead of bare uppercase\n  section labels, giving the form a visible spine.\n- **Required-field dots.** Team, Client, and Project name labels show\n  a subtle accent dot so the user can tell at a glance which fields\n  gate the Create button.\n\n## Client requirement: auto-select on single client\n\nSURF-4 made `clientId` required on every project; new orgs with a\nsingle \"Internal projects\" baseline client used to see a red\nPick-a-client error on every fresh sheet. The sheet now auto-selects\nthe client when the org has exactly one — with a small \"Auto-selected\n— change in CRM → Clients if needed\" hint so the action stays\ndiscoverable.\n\n## Settings page: image identity editor\n\nProject Settings (`/projects/$projectId/settings`) gained the same\nimage-identity option as a new \"Identity image (URL)\" field below\nthe Icon/Color row. The Identity preview tile reflects the chosen\nmode in real time. Clearing the URL falls back to the icon/initials\ntile.\n\n## Renderers updated\n\nProject tile components consume `imageUrl` with image > icon >\ninitials precedence:\n- `<ProjectBadge>` in the sidebar\n- Project detail header tile (`/projects/$projectId`)\n- Project Settings header avatar + Identity preview","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:28.656Z","updatedAt":"2026-06-07T07:34:28.656Z"},{"id":"199876b6-1c6a-40b9-bf8b-46c7b028cc97","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-23-identity-polish","type":"changed","scope":"projects","summary":"Polished project identity across Settings + every tile renderer — 3-mode switcher in Settings, image-load skeletons, graceful onError fallback.","body":"R88.23 — Five-phase follow-up to R88.22's project identity overhaul.\n\n## Settings Identity now mirrors the create sheet\n\nThe project Settings page's Identity card got the same three-mode\nsegmented switcher (Initials / Emoji / Image) the create sheet\nshipped in R88.22. Picking a mode also clears the OTHER modes'\ncompeting fields so the renderer's precedence (image > icon >\ninitials) actually matches the user's intent on Save — no more\n\"I'm in initials mode but the saved emoji still shows\".\n\nOn open, the mode is derived from the project's current data\n(`p.imageUrl ? 'image' : p.icon ? 'icon' : 'initials'`) so users\nopening Settings see their current mode pre-selected.\n\n## Image-load skeleton + graceful fallback\n\nThe Settings preview tile now:\n- pulses a skeleton while a freshly-pasted URL fetches\n- falls back to icon/initials behind the image so the box is never\n  empty during the fetch window\n- shows a small \"Image could not be loaded\" warning under the URL\n  input when the image errors out (404, CORS, expired signed URL)\n- tints the input border with the danger token when the load fails\n\n## Every tile renderer now has onError fallback\n\nWhen a stored image URL goes stale or 404s, the sidebar, project\ngrid card, and detail page header all silently swap to the\nicon/color/initials chain instead of showing the browser's\nbroken-image glyph:\n\n- `<ProjectBadge>` (sidebar): wraps the image in a\n  `<ProjectBadgeImage>` with `onError` → re-render through the\n  fallback chain\n- `<ProjectCard>` (projects grid): new `<ProjectCardTile>` with the\n  same precedence + fallback, also shows a muted name-initial tile\n  when no identity is set so card layout is consistent across the\n  grid\n- `<ProjectHeaderTile>` (detail page header): same onError contract\n  + same fallback chain\n\n## Bonus polish\n\n- ProjectCard now shows a small 24px identity tile next to the\n  project name in the grid, matching the visual language of the\n  sidebar + page header.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:29.360Z","updatedAt":"2026-06-07T07:34:29.360Z"},{"id":"bec97335-87c6-4a6c-b833-17e9adf47515","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-24-identity-everywhere","type":"changed","scope":"projects","summary":"Project identity flows through breadcrumb + board cards + cross-module compact rows; new quick-preset picker; lazy-loading on all project image renderers.","body":"R88.24 — Five-phase polish round bringing project identity to every\nremaining render surface.\n\n## Cross-module compact projection learned identity\n\n`projects.project.get_compact` now returns `imageUrl` alongside the\nexisting color + icon, so every cross-module renderer (task detail\nbreadcrumb, ADR-0013 link cards, AI mention chrome) can show the\nproject's full identity without a second round-trip to the full row.\n\n## Task detail breadcrumb tile\n\nThe project + parent-project crumbs in the task detail sheet\nupgraded from a small 8px color dot to a 14px identity tile with\nthe same precedence as the sidebar (image > icon + color >\ncolor dot). onError on the image silently falls back to the\nicon/color branch so a stale CDN URL never leaves a broken-image\nglyph in the breadcrumb.\n\n## Board card project chip\n\nWhen the multi-project tasks view (`/projects/tasks`) renders board\ncards, the project chip at the bottom-right of each card now leads\nwith the identity tile + truncated name (was: just the name). The\nchip widens by ~30px to accommodate the tile.\n\n## Identity quick presets\n\nThe New Project sheet's Emoji mode now offers six one-tap presets:\nEngineering, Marketing, Design, Operations, Research, Sales. Each\nsets both icon + color in a single click so users don't have to\nmanually compose a theme. Mirrors Linear / Notion's project-\ntemplate chrome.\n\n## Performance: lazy + async image loading\n\nEvery project image renderer (sidebar badge, project card grid,\ndetail page header, Settings preview, create-sheet preview,\nbreadcrumb tile, board card chip) now ships with `loading=\"lazy\"`\nand `decoding=\"async\"` so the browser can defer + parallelize the\nnetwork + decode work. Most noticeable when the sidebar renders\nmany image-identity projects on first paint.\n\n## Schema + projection changes\n\n- `ProjectCompactRow` Zod schema + Drizzle projection updated to\n  include `imageUrl`. No new migration — image_url already exists.\n- `task-views/types.ts:ProjectOpt` extended with optional\n  `color / icon / imageUrl` so the board's `projectsById` map\n  carries identity into the card renderer.\n- `tasks.tsx` populates the extended ProjectOpt from\n  `projects.project.list` (which already returns these fields).\n\nTests: 14 project-list action tests pass.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T07:34:29.375Z","updatedAt":"2026-06-07T07:34:29.375Z"},{"id":"fbafdeb3-9b8e-4d17-960a-654ee112b2de","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-25-kanban-card-polish","type":"changed","scope":"projects","summary":"Kanban board cards get expanded visual presence — status glyph, subtask + future-start chips, freshness pulse, refined hierarchy + filter dropdown icons.","body":"R88.25 — Six-phase polish on the kanban board card + form pickers.\nCards now carry more visual data without becoming cluttered, and\nfilter dropdowns have the same icon vocabulary as the cards.\n\n## Card additions\n\n1. **Status icon chip** in the header row. Renders the canonical\n   `STATUS_ICON` glyph (CircleDashed / Circle / CircleHalf / Eye /\n   CheckCircle / XCircle) tinted with the column accent. Reinforces\n   the card's status context when the column header scrolls off\n   or when reading several cards stacked vertically.\n\n2. **Sub-task indicator.** When `parentTaskId` is set, an inline\n   \"Sub-task\" chip with an `ArrowBendDownRight` glyph surfaces the\n   parent relationship at a glance. Collapses to a glyph-only mode\n   when a project chip already claims the right gutter.\n\n3. **Future-starts-at chip.** When `startsAt` is in the future, a\n   small `Clock`-led pill renders next to the due date showing\n   when the task is scheduled to begin. Tinted info-blue so it\n   reads distinctly from the due-date calendar pill.\n\n4. **Freshness pulse.** Cards updated in the last hour get a small\n   accent dot pulsing in the top-right corner. Helps returning\n   users scan \"what changed since I stepped away\" without a\n   dedicated filter. Skips completed / canceled tasks where the\n   relevant change is already encoded in the column position.\n\n## Visual hierarchy\n\n5. **Refined chrome.** Two-layer shadow at rest + four-layer on\n   hover for gentle elevation lift. Priority left stripe widened\n   from 3px to 4px with `rounded-r` and an urgent-only soft\n   accent-danger glow. Title bumped to 14.5px with `-0.011em`\n   tracking for stronger hierarchy with the metadata row. Due-\n   date pill now uses `CalendarBlank` instead of the custom SVG\n   for visual consistency.\n\n## Forms\n\n6. **Filter dropdown icons.** The filter-builder's Status + Priority\n   menus now lead each option with the canonical glyph (status:\n   STATUS_ICON in STATUS_TONE; priority: PRIORITY_ICON in\n   PRIORITY_TONE). Pseudo-values (Any / Open) get a `ListChecks`\n   glyph so they read as filter shortcuts, not real states.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T08:42:27.397Z","updatedAt":"2026-06-07T08:42:27.397Z"},{"id":"a356926d-d789-4f6e-a138-360f4f98b74b","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-26-creation-settings-typography","type":"changed","scope":"projects","summary":"Modernized project creation + settings typography, surfaced required-field indicators, image-load skeleton, and Cmd+S discoverability in the settings header.","body":"R88.26 — Five-phase polish round on the project creation sheet and\nsettings page, targeting visual hierarchy + discoverability.\n\n## Phase 1 — Modern FieldLabel typography\n\nThe sheet's `FieldLabel` was `uppercase tracking-[0.06em]\ntext-[10.5px]` — it read like a 2015 admin form. Now `text-[11.5px]\nfont-medium` in normal case, matching Linear / Notion / Vercel form\nlabels. The required-field accent dot stays right next to the label\ntext so the visual contract is unchanged.\n\n## Phase 2 — Required indicators in Settings\n\nThe Settings page's `Field` component gained a `required` prop that\nrenders the same accent-danger dot the sheet's `FieldLabel` uses.\nName and Client are now flagged as required so users can tell at a\nglance which fields gate Save. Mirrors the sheet's pattern so create\n+ edit feel symmetric.\n\n## Phase 3 — Mode content de-boxed\n\nThe sheet's identity mode-content (Initials / Emoji / Image picker\ncontent) was wrapped in its own `border + bg-subtle/30` panel\ninside the SectionCard, creating a \"box within a box\". The chrome\nis gone; the mode picker now reads as a continuation of the\nIdentity section.\n\n## Phase 4 — Image-load skeleton in the create sheet\n\nThe sheet's image-mode preview pulses a `<Skeleton>` overlay while\na freshly-pasted URL fetches, falls back to the icon/initials\nbehind the image at all times, and fades the image in once loaded.\nMatches the polish the Settings preview tile shipped in R88.23 so\nboth surfaces share the same image-load chrome.\n\n## Phase 5 — Cmd+S discoverability + description demoted\n\nThe Settings page header now shows a small `⌘ S to save` keyboard\nhint in the subheading (always visible, not gated on dirty state).\nPower users discover the shortcut before they make their first\nedit instead of waiting for the SaveBar to appear.\n\nThe Description field demoted from its R88.23 position above\nVisibility to below the date range. The form now leads with the\nrequired + structural fields (Name → Visibility → Client → Dates)\nso what gates Save is what the user sees first.\n\n## What's preserved\n\nThe R88.22–R88.25 polish stack is intact: 3-mode identity switcher,\nidentity-tile-everywhere, freshness pulse on board cards, etc.\nThis round only changed typography, chrome, and IA — no behaviour\nchange.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T13:55:29.259Z","updatedAt":"2026-06-07T13:55:29.259Z"},{"id":"b39490ac-a886-4e36-986c-2d61634ece7e","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-27-section-nav-and-affordances","type":"changed","scope":"projects","summary":"Settings gets a section-jump nav + saved-success flash; the create sheet gets a \"Create another\" affordance and scroll-to-invalid on submit.","body":"R88.27 — Four-phase polish on the project creation sheet + settings\npage, focused on long-form navigation, power-user affordances, and\nsubmission feedback.\n\n## Phase 1 — Settings section nav\n\nSettings is a long page (Identity / Details / Status / Hierarchy /\nMembers / Client messages / Client files / Custom fields /\nRecurring / Danger zone). A horizontal scroll-snap pill row now\nsits right under the page header listing every section; clicking a\npill smooth-scrolls to its anchor. Each section / panel wrapper\ncarries an `id=\"section-…\"` + `scroll-mt-20` so the scrolled\nsection doesn't sit flush against the viewport edge. The \"Danger\nzone\" pill takes the accent-danger tint so it visually telegraphs\nits severity.\n\n## Phase 2 — \"Create another\" affordance in the sheet\n\nPower users bulk-creating projects in the same team / category\ncontext can now check a small \"Create another\" checkbox in the\nsheet footer. When checked, after a successful Create the sheet\nstays open and the form does a partial reset (name / slug /\ndescription / icon / image cleared) while preserving the\nstructural fields (team / category / client / visibility /\nparent). Saves several clicks per project.\n\n## Phase 3 — Saved success animation in SaveBar\n\nAfter a Save completes on the Settings page, the SaveBar overrides\nits \"hide when clean\" behaviour for ~1.8s and flashes a \"Saved\"\nstate with an `accent-success` check icon + tinted border. The\naction buttons hide during the flash; the bar slides out\nnaturally once the timer expires. Gives users in-place visual\nconfirmation beyond the toast.\n\n## Phase 4 — Scroll to first invalid field on submit\n\nWhen the sheet's Create button is clicked with an empty required\nfield (team / name / client) or a known-bad state (slug collision\n/ invalid image URL), the form now smooth-scrolls the offending\nfield into view + focuses it after the scroll completes. Anchors\nland at `scroll-mt-20` so they don't end up flush against the\nsheet's sticky header. The error banner in the footer still tells\nthe user what; this brings them to where.\n\n## What's preserved\n\nThe R88.22–R88.26 polish stack is intact: 3-mode identity\nswitcher, required-dot field indicators, image-load skeleton,\nCmd+S in the Settings header, modern FieldLabel typography, etc.\nThis round only added new chrome + behaviour; no existing\nbehaviour was changed.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:54.900Z","updatedAt":"2026-06-07T17:58:54.900Z"},{"id":"4e530a67-0be0-4606-a9f7-58613003492f","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-29-hierarchy-and-detail-polish","type":"changed","scope":"projects","summary":"ProjectHierarchyPanel + project-detail header / sub-projects rail / milestone rail polished — themed Detach confirmation, identity tiles on child cards, header micro-interactions.","body":"R88.29 — Four-phase polish round catching up the remaining inner\npanels + project-detail page surfaces to the rest of the module's\nvisual standard.\n\n## Phase 1 — ProjectHierarchyPanel\n\n- Header chrome aligned (was missing the `bg-[var(--bg-subtle)]/40`\n  tint every other section uses). Added a count chip in the\n  header so sub-project density reads at a glance.\n- Detach button no longer fires immediately — a themed\n  ConfirmDialog explains the consequence (\"becomes a top-level\n  project; tasks/members/history stay intact; you can re-parent\n  later\") before the mutation runs.\n- Detach button gained the standard hover scale + active press\n  + danger-tinted hover background + focus ring.\n- The standalone `detachChild` helper was removed; the\n  ConfirmDialog state lives inline in the panel.\n\n## Phase 2 — Project detail header action buttons\n\nThe AI cluster (Plan with AI / Status update) and Apply template\nbutton picked up the standard scale-on-press feedback the rest of\nthe module uses. The AI Sparkle glyph also `scale-110`s on hover\nso the affordance reads as activated, not static.\n\n## Phase 3 — SubProjectsPanel (service tracks)\n\n- Cards lift +1px on hover with a soft shadow swell + active\n  `scale-[0.995]` press cue, matching the kanban-card vocabulary.\n- Avatar tile precedence upgraded to image > icon > initials,\n  matching the sidebar + page header + grid card. Replaces the\n  hardcoded `'#ffffff'` text color with `readableTextOn(color)`\n  for legibility on light hues.\n- \"+ Add sub-project\" header button gained hover scale.\n- ChildProjectRow type now carries imageUrl (already returned\n  by the server since R88.22).\n\n## Phase 4 — MilestoneRail buttons\n\n- Ghost \"Add milestone\" header button picked up the scale-on-\n  press vocabulary.\n- Empty-state primary \"Add milestone\" CTA gained soft shadow\n  lift + hover scale so it reads as the actionable affordance\n  it is, not a static button.\n\nWeb typecheck: no new errors from the touched files.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:55.599Z","updatedAt":"2026-06-07T17:58:55.599Z"},{"id":"6f3a44c9-695c-4021-ba53-50f2aa7a26c5","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-28-settings-panels-polish","type":"changed","scope":"projects","summary":"Sticky scroll-spy section nav on Settings, plus polish on the Members / Custom Fields / Recurring panels (themed confirmations + button micro-interactions).","body":"R88.28 — Four-phase polish round extending R88.27's settings work.\nThe new section nav is now sticky + scroll-spied; the inner panels\n(Members, Custom Fields, Recurring) catch up to the rest of the\nmodule's visual standard.\n\n## Phase 1 — Scroll-spy + sticky section nav\n\nThe R88.27 section nav now sticks to the top of the viewport with a\nfrosted-glass treatment (`bg-[var(--bg-default)]/85 backdrop-blur-md`)\n+ soft shadow, and uses an IntersectionObserver to track which\nanchored section is in view. The active pill highlights with an\naccent-tinted background + medium weight; the Danger zone pill\nflips to its accent-danger tint when active. On narrow viewports\nthe nav also auto-scrolls horizontally to keep the active pill\nvisible.\n\n## Phase 2 — ProjectMembersPanel\n\n- Header chrome aligned to the module convention: tinted\n  `bg-[var(--bg-subtle)]/40 px-4 py-2.5` with the right text\n  weight + tracking. The settings page's wrapping section was\n  dropping a duplicate \"Members\" header — that's gone too; the\n  panel owns its header now.\n- Native `confirm()` for \"Remove member\" replaced with a\n  ConfirmDialog. Themed, keyboard-navigable, matches the\n  Danger-zone pattern. Description tells the user what happens to\n  tasks + comments (they stay; only the membership row is removed).\n- Remove button gained a `hover:scale-110` micro-interaction.\n\n## Phase 3 — CustomFieldsAdminPanel\n\n- Native `confirm()` for \"Delete field\" replaced with a themed\n  ConfirmDialog explaining the cascade (\"values stored on tasks\n  will be lost — this cannot be undone\").\n- Edit / Delete buttons gained hover scale + active-press\n  micro-interactions; Delete's hover tint is now\n  `accent-danger/10` so the destructive action telegraphs its\n  intent.\n- Reorder (up/down) buttons got the same hover scale + active\n  press + disabled-scale-100 (a disabled button shouldn't be\n  bouncy).\n- Added `toast.success(\"Custom field saved\")` after the editor's\n  onSaved callback; previously only the query invalidated and\n  the user got no explicit confirmation.\n\n## Phase 4 — RecurringTasksPanel\n\n- Pause / Resume + Edit icon buttons gained the same micro-\n  interaction vocabulary (hover scale 1.10, active scale 0.95,\n  disabled scale-100). Pause/Resume also picks up\n  `aria-busy` while the mutation is pending.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-07T17:58:55.618Z","updatedAt":"2026-06-07T17:58:55.618Z"},{"id":"bb8ff099-91b1-492a-b12c-091a8f98311a","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-30-cycle-rail-and-inline-editors","type":"changed","scope":"projects","summary":"CurrentCycleRail gets a card lift, pulse on its status dot, and a danger-tinted overdue state; inline status + priority pills pick up the standard tactile press feedback.","body":"R88.30 — Two-surface polish round: the CurrentCycleRail banner on\nthe project detail page, and the inline status + priority editors\nthat appear on every task row.\n\n## Phase 1 — CurrentCycleRail\n\n- Card now lifts `-1px` on hover with a soft shadow swell + active\n  `scale-[0.997]` press cue, matching the kanban-card vocabulary.\n- Live status dot pulses (`motion-safe:animate-pulse`) with a\n  16% accent-success halo via `box-shadow` so the \"this is\n  running\" cue is visible at rest.\n- Overdue cycles (`daysRemaining < 0`) now flip the dot, the\n  \"X days overdue\" line, and the progress bar to\n  `accent-danger`. Cycles ending within 2 days flip the day-count\n  line to `accent-warning`. The at-risk state now reads at a\n  glance instead of requiring a careful read of the date strip.\n\n## Phase 4 — Inline status + priority editors\n\n- The InlineStatusEditor + InlinePriorityEditor pills now ship\n  with the standard `hover:scale-110 active:scale-95` micro-\n  interaction so they read as clickable. Disabled state cancels\n  the transform so a pending mutation doesn't keep bouncing\n  (`disabled:scale-100`).\n- Menu items inside both pickers gained `active:scale-[0.98]` +\n  `transition-[background-color,transform] duration-100 ease-out`\n  so the click registers as a real interaction, not just a\n  background flash.\n\n## Phases 2 + 3 — Deferred\n\n- ProjectTabBar was already well-polished (R88.13 + R88.5) —\n  no changes needed.\n- `/projects/$projectId/data` is 4456 lines; out of scope for\n  this round, will revisit in a dedicated pass.\n\nWeb typecheck: no new errors from the touched files.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:54.581Z","updatedAt":"2026-06-08T16:37:54.581Z"},{"id":"45040bb0-f3b4-4c3b-a2d9-128fa3fd72ea","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-31-inline-calendar-gantt-stats","type":"changed","scope":"projects","summary":"Remaining inline editors (Done toggle / Assignee / Due date), calendar + gantt task pills, and ProjectStatsSummary all pick up the standard tactile feedback + attention cues.","body":"R88.31 — Four-phase polish bringing the rest of the project module's\nclickable surfaces in line with the micro-interaction vocabulary\nestablished in R88.25 → R88.30.\n\n## Phase 1 — Remaining inline editors\n\n- `RowDoneToggle` (round complete checkbox): hover scale-110 +\n  active scale-90 + disabled scale-100 + focus-visible ring;\n  the unstarted state gains a 14% accent-success halo on hover\n  so the \"I'm about to mark this done\" preview is visible.\n- `InlineAssigneeEditor` avatar trigger: hover scale-110 +\n  active scale-95 + disabled scale-100, matching the status /\n  priority pill micro-interactions.\n- `InlineDueDateEditor` pill: hover scale-[1.06] + active\n  scale-95 + disabled scale-100. The unset state keeps its\n  group-hover reveal pattern but picks up the scale once\n  visible.\n\n## Phase 2 — Calendar view task pills\n\n- The day-cell task chips now lift -1px on hover with a soft\n  shadow swell + active scale-[0.98] press cue (kanban-card\n  vocabulary), and gain a focus-visible ring.\n- The \"next-up\" overflow pills at the top of the calendar pick\n  up the standard scale-on-press.\n\n## Phase 3 — Gantt view\n\n- Overflow task pills get the same scale-on-press as the\n  calendar's.\n- Gantt row labels gain a focus-visible inset accent ring so\n  keyboard navigation has a clear target.\n\n## Phase 4 — ProjectStatsSummary\n\n- The Overdue stat (when count > 0) now pulses with a soft\n  accent-danger halo so it draws the eye on a busy detail\n  page. Honors `motion-safe`.\n- The completion progress bar fades a soft accent-success\n  glow when the project hits 100%; the `%` number flips to\n  accent-success to mark the milestone. Both transitions\n  ride the same 300–500ms band so the celebration lands\n  visually.\n\nWeb typecheck: no new errors from the touched files.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-08T16:37:55.378Z","updatedAt":"2026-06-08T16:37:55.378Z"},{"id":"af2110f6-976d-4ecb-8af2-b1739a82b199","releaseId":"3a299d3a-c52b-4929-a049-de72cec794c3","slug":"projects-r88-32-subproject-header-layout","type":"fixed","scope":"projects","summary":"Sub-project header layout no longer fragments — title, status pill, and right action cluster now line up cleanly on a single row regardless of whether the project has a parent.","body":"R88.32 — Three-phase fix for the project detail header on\nsub-projects (or any project without an identity tile). User\nreported the layout looked \"not optimized\" for sub-projects; the\nparent project header rendered cleanly on one row but the sub-\nproject header fragmented the right action cluster onto two rows.\n\n## Phase 1 — ProjectHeaderTile always renders\n\n`<ProjectHeaderTile>` previously returned `null` when the project\nhad no `imageUrl`, `icon`, or `color` set — a common state for\nsub-projects that inherit nothing from their parent. The title\nthen floated without a visual anchor next to it.\n\nNow the tile always renders. When no identity is set, it falls\nback to a muted `bg-emphasis` square with initials derived from\nthe project name (first letter of each of the first two words,\nmatching the sidebar tile fallback). The header gains a\nconsistent visual anchor across every project, top-level or\nsub.\n\n## Phase 2 — Parent breadcrumb relocated above the header\n\nThe \"← Parent project\" breadcrumb previously rendered inside the\nheader's left column above the title. That made the left column\ntwo rows tall while the right action cluster (`items-start`)\nstayed at row 1, leaving the title visually disconnected from\nits own actions — buttons sat alongside the breadcrumb instead\nof the title.\n\nThe breadcrumb now renders as its own thin pre-header row. The\nheader proper has only the title + status pill on the left and\nthe action cluster on the right, aligned on one row.\n\n## Phase 3 — Right cluster `flex-nowrap`\n\nThe right action cluster used `flex-wrap` internally, which\ncaused the AI button pair to land on one row and Apply Template\n+ New Task to wrap to a second row in sub-project headers\n(driven by complex flex-basis math from the parent's\n`justify-between`). Now `flex-nowrap` so the cluster always\nstays a single visual unit. The outer header's `flex-wrap` still\nlets the whole cluster wrap below the title on narrow viewports\n— the right behaviour is \"keep actions together\".\n\n## Bonus\n\nHeader alignment switched from `items-start` to `items-center`\nso the right action cluster vertically centers with the title +\nstatus pill regardless of how tall the left column's meta lines\n(team · slug · client tag) push.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":null,"internalOnly":false,"createdAt":"2026-06-13T08:10:07.181Z","updatedAt":"2026-06-13T08:10:07.181Z"}]},{"id":"2ca08ec1-85bb-482d-b990-3982c11c2032","tag":"R+5","slug":"r-5","version":null,"title":"Per-push changelog, platform status, marketing polish","summary":"First live release after the R+1-R+4 historical backfill. Adds the platform-tier changelog system with per-commit enforcement, the public status page and incident pipeline, the latest marketing polish, the hak_ bearer-auth wire-up that made user API keys actually authenticate, plus the admin UI v2 + public page v2/v3 redesigns with module taxonomy view, animated collapsibles, and refined typography.","status":"published","publishedAt":"2026-05-23T21:17:47.358Z","periodStartsAt":null,"periodEndsAt":"2026-05-23T21:17:47.358Z","coverImageUrl":null,"notifyOnPublish":false,"tags":[],"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.384Z","entries":[{"id":"da3de92b-bb73-4741-977e-d7ee178a8a1a","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"api-key-platform-scopes","type":"added","scope":"support","summary":"API-key service tokens (`hsk_`) can now carry `platform_changelog_{read,manage}` scopes for scripted publishes.","body":"Two new scope values on `support.api_key.create`:\n\n- `platform_changelog_read` → grants `platform:changelog:read`\n- `platform_changelog_manage` → grants `platform:changelog:read` + `platform:changelog:manage`\n\nThis unblocks `pnpm changelog:publish` from a CI runner or a local\nterminal authenticating via Bearer — previously the only `hsk_` scopes\nwere Support-ticket scoped, so an operator running the publish CLI had\nto fall back to cookie-session auth.\n\nOnly mint these scopes for trusted operators / CI runners; they carry\nroot-only permissions. Other platform-tier scopes can join the same\nenum as new automation needs land.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-api-key-platform-scopes.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"},{"id":"03042099-caf8-4d97-b68a-74f7c9f3128e","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"dockerfile-changelog-staging","type":"fixed","scope":"infra","summary":"Production images now ship `.changelog/unreleased/` so the admin \"Pending entries\" tab works in deployed environments.","body":"`turbo prune --docker` strips the repo-root `.changelog/` directory\nbecause it isn't part of any pruned package. Result: the admin UI's\n`platform.changelog.preview_staging` action returned 0 pending entries\non staging / prod even when entries existed at the source commit's\nrevision.\n\nAdded an explicit `COPY --from=pruner /app/.changelog /app/.changelog`\nin the runner stage. Now the \"Pending entries\" tab + the `pendingStaging`\ncounter on the stats header reflect what's actually committed at the\ndeployed revision.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-dockerfile-changelog-staging.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"},{"id":"01a049fb-99c5-40f7-8df6-52a202d48151","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"marketing-brand-mark-and-nav-polish","type":"changed","scope":"marketing","summary":"Add a designed HeliosMark glyph + cta-lift on the nav Get Started button.","body":"Extract the brand glyph (rounded primary square + H letterform in\nprimary-fg) into a shared `<HeliosMark>` component. The un-branded\nfallback in the TopNav is now this designed glyph instead of a plain\nsolid primary square — so deployments without a custom uploaded logo\nalready look intentional. Theme tokens still flow through, so a\nwhite-label re-brand via `platform_settings.app_color_primary`\nswaps the colour automatically.\n\nThe CompetitiveMatrix header now uses the same shared component\n(previously had a near-identical inline copy).\n\nPlus `cta-lift` on the nav Get Started button so the page's primary\nacquisition CTA carries the same micro-interaction as the hero,\nclosing-call, action-contract, and partners CTAs.\n\nOptional `withPulse` prop on `<HeliosMark>` adds a soft outward\nprimary ring loop for places we want to signal \"live.\"","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-brand-mark-and-nav-polish.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"},{"id":"d65b9d07-126a-4d69-872c-aefda10cfb61","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"marketing-code-tabs-polish","type":"changed","scope":"marketing","summary":"Polish CodeTabs with window chrome, line numbers, and lightweight syntax highlighting.","body":"The §5 ActionScaffoldReveal \"code-as-proof\" block is the central\nargument of the home page — but `<CodeTabs>` rendered the snippets\nas plain monospace gray, which read like a documentation paste\ninstead of \"real product code in a real editor.\"\n\n`<CodeTabs>` is now elevated:\n\n- Window chrome at the top — three traffic dots (danger / warning /\n  success), the filename extracted from the active tab label, and a\n  language pill (TS / SH / JSON) on the right.\n- Line-number gutter on the left of every code body, with a thin\n  divider rule against the surface tone.\n- Lightweight regex-based syntax highlighting, tuned per-language:\n  - **TypeScript / TSX:** comments italic muted, strings success-500,\n    numbers warning-500, keywords (import/export/const/async/...)\n    primary semibold, type/class names accent.\n  - **Bash / curl:** comments muted, strings success-500, `$VARS`\n    accent semibold, `--flag` arguments primary, numbers warning-500.\n  - **JSON / MCP:** JSON keys accent, string values success-500,\n    numbers warning-500, true/false/null primary semibold, line/hash\n    comments muted.\n\nNo Prism / Shiki dependency — the highlighter is ~70 lines and good\nenough for the marketing surfaces. If a future docs site needs full\nlanguage support, we'll swap in Shiki at build time then.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-code-tabs-polish.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"},{"id":"841e07bc-172f-48a8-82e6-a86a0ec19f73","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"marketing-final-placeholder-purge","type":"changed","scope":"marketing","summary":"Replace remaining ScreenshotPlaceholders on the home with designed mosaics + add animated ROI counter.","body":"The last three sections still leaning on labelled `<ScreenshotPlaceholder>` slots\nare now real designed compositions:\n\n- **ClosingCall** (page-bottom CTA): the four placeholder tiles are gone. The\n  right column now reuses the hero's live `bento-cells` — CrmDealsMini on top,\n  HrProfileMini + CalendarMini paired, AuditLogTail bottom (2px primary border,\n  pulse dot). The audit-log component now genuinely opens AND closes the page.\n- **DemoVideoPanel** (4-minute walkthrough): replaced the 16:9 placeholder with\n  a designed `FauxDashboardPoster` — vertical module sidebar (Helios H + 9\n  icons), top tab bar (CRM/Deals · 124 open · Live · JD avatar), 5-row deals\n  table mock (Anthropic / Linear / Figma / Notion / Stripe). Strong radial\n  vignette keeps the play button as the focal point.\n- **RoiCalculator** (the math section): big savings number now animates via\n  `requestAnimationFrame` on every slider tweak (ease-out cubic, 600ms). New\n  \"cost vs today\" bar visualizes Helios spend as a fraction of today's\n  per-tool spend; the line-through \"Today 100%\" caption ties it to the\n  savings claim above. The big-number card gains a primary radial glow + a\n  pulse-dot. All animations honor `prefers-reduced-motion`.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-final-placeholder-purge.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"},{"id":"11c5ccad-652c-4e88-bdf6-0a908188829b","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"marketing-hero-visibility-fix","type":"fixed","scope":"marketing","summary":"Make the bento hero visible — selector mismatch was hiding every cell.","body":"The bento hero grid had `reveal`, `is-revealed`, and `reveal-stagger` all\non the same element, but the stagger CSS selector\n(`.reveal.is-revealed .reveal-stagger > *`) requires `.reveal-stagger` to\nbe a **descendant** of `.reveal.is-revealed`, not the same element. So\nevery hero cell stayed at `opacity: 0` forever — the section rendered\nblank.\n\nThe hero is above the fold; no entrance stagger is needed there\nanyway. Dropped the three classes from the grid so the cells are\nvisible immediately on page load.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-hero-visibility-fix.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"},{"id":"c1a0fede-e215-4f89-ac8f-f661dc809a40","releaseId":"2ca08ec1-85bb-482d-b990-3982c11c2032","slug":"marketing-screenshot-placeholder-wireframe","type":"changed","scope":"marketing","summary":"Replace ScreenshotPlaceholder with a stylized wireframe mock that adapts to aspect ratio.","body":"`<ScreenshotPlaceholder>` is rendered ~19 times across the home (14\nModuleBento tiles + 5 FeatureSpotlight stops + a few elsewhere). The\nprevious \"labelled dashed box\" felt like empty placeholder we forgot\nto fill in.\n\nThe replacement renders an actual wireframe mock — browser chrome\n(traffic dots + route URL + \"to capture\" pip), then a body layout\nthat adapts to the aspect ratio:\n\n- ratio ≥ 2.0 (wide tiles) → table-row wireframe\n- ratio < 1.1 (tall / square) → sidebar wireframe (avatar + stat tiles)\n- everything else → dashboard wireframe (sidebar nav + content grid)\n\nThe describes text and recommended size move to a hover overlay that\nslides up from the bottom, so the \"what to capture\" capture brief\nstays available without cluttering the visual.\n\nOn hover, a brand-tinted diagonal shimmer travels across the\nwireframe (1.6s ease-out loop, skewX(-12deg)) — gives the mock the\n\"alive\" feel of skeleton screens without faking a load state. Hidden\nunder prefers-reduced-motion.\n\nThe module-accent strip across the top edge + the soft accent radial\nglow + the existing dotted-grid texture are preserved.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["claude-code"],"sortOrder":"500","tags":[],"sourceKind":"file","sourcePath":".changelog/unreleased/2026-05-23-marketing-screenshot-placeholder-wireframe.md","internalOnly":false,"createdAt":"2026-05-23T21:17:47.360Z","updatedAt":"2026-05-23T21:17:47.360Z"}]},{"id":"996b4688-a9fd-4d06-9fce-67a53cdd058b","tag":"R+4","slug":"r-4-modules-ship-ready-2026-05-18","version":null,"title":"Modules ship-ready — HRM gap closure, recruitment ATS/CV, website CMS, careers, maintenance mode","summary":"HRM gap closure all 8 phases complete (140 pts). Recruitment got ATS scoring on every signal, high-end CV parser (no-AI fallback + AI path), drag-and-drop pipeline, interview scorecards, candidate↔job fit scoring, dynamic offer letters. Website CMS module landed with Phases 5–12 + 14-block visual composer + media library. Auth Phases 10–14 closed LDAP / SAML 2.0 / push-2FA / cross-tab realtime. Platform maintenance mode shipped (manual / scheduled / deployment triggers). Branded error pages live.","status":"published","publishedAt":"2026-05-22T20:00:00.000Z","periodStartsAt":"2026-05-18T00:00:00.000Z","periodEndsAt":"2026-05-22T23:59:59.000Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["modules","recruitment","hrm","website","historical-backfill"],"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.801Z","entries":[{"id":"b65d59dc-52a6-4b0b-a544-5a6a8ae6a3ff","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"hrm-gap-closure-complete","type":"added","scope":"hrm","summary":"HRM gap closure — all 8 phases shipped (140 pts).","body":"Timezone helper, PII gating, leave rollover cron, perf review cycles, configurable onboarding requirements, offboarding workflow, profile edit-gating, confetti + Need-help panel. Playwright E2E for joining-pack deferred.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/hrm-gap-closure-complete.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"45777332-e20c-401a-afc4-707d28961bd3","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"hrm-offboarding-workflow","type":"added","scope":"hrm","summary":"HRM offboarding workflow — hrm_offboarding_tasks table, 8 actions, 2 subscribers, /hrm/offboarding hub + per-employee checklist UI.","body":"Migration 0167_0168. Seed-on-terminate subscriber + auto-complete on asset.returned + user.access_revoked. 13 new tests; 271 HRM tests green.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/hrm-offboarding-workflow.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"01e0f059-ad13-4107-b09a-e792f7fdbf17","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"hrm-leave-payout","type":"added","scope":"hrm","summary":"Cross-currency leave payout (hrm.leave.compute_payout) + perf review encryption at rest.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/hrm-leave-payout.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"1e11b0db-a03b-4695-a9d1-692f53aed6d3","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"hrm-ai-drafted-templates","type":"added","scope":"hrm","summary":"AI-drafted joining-letter / contract / NDA template bodies (org can regenerate with their voice).","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/hrm-ai-drafted-templates.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"954bc980-f43d-467c-91b2-bd8f19fc4eac","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-ats-scoring","type":"added","scope":"recruitment","summary":"High-end ATS score on every signal — rubric + AI, sortable list, score-method badge, manual trigger.","body":"Auto = free rubric; manual AI trigger uses the org's AI config. Score + summary on application cards + list view; portfolio field; AT representation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-ats-scoring.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"c9e404d1-ce92-46ba-8092-777b0d0e2e15","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-cv-parser","type":"added","scope":"recruitment","summary":"High-end no-AI CV parser — skills taxonomy, job-title gazetteer, blob recovery, phone validation, dates.","body":"CV parser round 3 — comprehensive extraction across layouts. \"Extracted data\" view shows what was pulled. Fallback when AI is not configured; AI path routes through the org's config.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-cv-parser.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"22104097-6490-4904-94d9-fd3e476ec38d","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-pipeline-dnd","type":"added","scope":"recruitment","summary":"Drag-and-drop pipeline with stage-action forms + immediate status sync + per-card quick-actions menu.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-pipeline-dnd.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"cacfba8d-ede7-448b-b4db-16de5453c09a","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-interview-scorecards","type":"added","scope":"recruitment","summary":"High-end interview scorecards — consensus verdict, recommendation pills, dedicated Scorecards tab.","body":"Panel interview rating surfaces on applicant cards + list.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-interview-scorecards.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"5fb29824-93c6-4228-b0ff-07c65c8a7966","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-fit-scoring","type":"added","scope":"recruitment","summary":"Candidate↔job fit scoring (rubric + AI, every signal).","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-fit-scoring.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"67ae72e9-0914-41f5-becb-c4746c06d521","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-careers-og","type":"added","scope":"recruitment","summary":"Careers OG images + link previews — 3 selectable templates, per-org config, crawler-safe meta injection.","body":"Server-rendered social previews + dynamic 1200×630 OG cards (satori/resvg) for the careers portal. Migration 0178_0179.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-careers-og.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"ef37e70d-4d0e-4b63-a5fb-46aa4e545333","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-job-to-offer-threading","type":"added","scope":"recruitment","summary":"Job → offer → HRM responsibilities threading — responsibilities + shift_id + reporting_manager_user_id across the funnel.","body":"Migration 0169_0170. Top-to-bottom: listing UI pickers, careers \"What you'll do\", offer-create prefill banner, JobHeader chips, recruit wizard \"Inherits from job\" preview, HRM joining-letter/contract clauses, employee-detail role card, candidate joining-pack \"Your role\".","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-job-to-offer-threading.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"a834a257-6d3b-47e3-927f-2ae25ea3388b","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-screening-dedupe","type":"fixed","scope":"recruitment","summary":"Dedupe + lock down screening-question system seeds.","body":"Migration 0170_0171 with NULLS NOT DISTINCT to prevent silent re-inserts.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-screening-dedupe.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"a8024d9f-e8df-4b3a-9709-7e613158045a","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-offer-dynamic","type":"added","scope":"recruitment","summary":"Fully variable-driven offer letter — no static terms. Jurisdiction-aware + reset-to-default.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-offer-dynamic.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"201af9d3-0d6c-43b7-9967-332d8df44249","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"recruitment-onboarding-tasks","type":"added","scope":"hrm","summary":"Onboarding task management — assignment + notifications (parity with offboarding) + active-tasks panel on dashboard + employee detail.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/recruitment-onboarding-tasks.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"2e1bbdf1-db08-45bf-9da5-badca676fb5f","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"website-cms-module","type":"added","scope":"website","summary":"Website CMS module — 16 actions, /saas/website/* admin, Astro 5 marketing with Cloudflare SSR + KV cache, 14-block visual composer, media library.","body":"Phases 5–12 + polishes. 25 .astro pages decomposed; per-CMS-page OG cards at build time; Pagefind SSR-stub indexing; 53 action tests passing. Migrations 0161_0162, 0166_0167 revisions, 0168_0169 media.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/website-cms-module.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"10f32d24-b51c-48bd-867c-7d1f672b30d7","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"website-branding-sweep","type":"added","scope":"website","summary":"Branding sweep across 17+ .astro pages + shared chrome + dynamic branding sanitizer.","body":"Plugged 5 sales + 1 manifest sentinel leaks bypassing the email-module branding resolver. The \"Helios\" literal no longer appears in any rendered template.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/website-branding-sweep.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"0b9b2e55-1deb-4eb7-a8ee-bced325c58d9","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"website-kv-cache","type":"performance","scope":"website","summary":"Workers KV cache with stale-while-revalidate on the marketing renderer.","body":"Phase 10C. CMS reads land in the edge within 60s; the SWR window keeps the next 10 minutes warm.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/website-kv-cache.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"0b26be49-862f-452b-b904-5a37123f1fac","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"platform-clone-prod-db","type":"added","scope":"infra","summary":"Clone prod DB → staging (script + one-click workflow).","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/platform-clone-prod-db.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"cfdcb5e1-134e-4a14-ad1e-71fae7e9fcbe","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"ai-command-center","type":"changed","scope":"ai","summary":"Command Center chat lands on the cost dashboard (U8). Defaults to Auto (routing rule).","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/ai-command-center.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"71c878cc-1e6b-417b-83c1-2eb5f165ea32","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"ai-unified-modules","type":"added","scope":"ai","summary":"Unified /api/ai/chat + /api/ai/catalog with the AI module; AI panel reads DB providers via the unified catalog (U1–U7).","body":"Auto-seed catch-all routing rule on first provider connect; legacy-fallback diagnostics; @helios mention handler resolves per-org AI route; forms.fill_with_ai + definition.translate wired to selectAi(); provider-kind validation + resolver cache invalidation.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/ai-unified-modules.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"58d99c39-3f11-4bea-bdfa-8e99922b44d4","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"chat-ui-revamp","type":"changed","scope":"chat","summary":"Chat UI revamp — 13 phases (Ask Helios persistent AI thread, mobile composer, first-run splash, polish sweep rounds 1–6).","body":"Sweeping visual + interaction polish: catch-up inbox, dividers, composer, typing indicator, hover card, URL preview, entity chip, dialogs, followups, search modal, pinned popover, banner, poll, forward, huddle, card embed, transcripts, modals, typography.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/chat-ui-revamp.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"593bee6e-f551-4292-99c2-4006c7c056c4","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"q1-q7-leave-offer-polish","type":"changed","scope":"hrm","summary":"Q1-Q7 leave + offer polish queue drained (9 commits) — DepartmentPicker on offer, leave notification fan-out, on-leave-today badge across 5 surfaces.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/q1-q7-leave-offer-polish.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"d78d215f-7e0f-4314-a05f-5535902491c5","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"white-label-branding-rule","type":"changed","scope":"email","summary":"White-label branding rule — no template/footer leaks the literal \"Helios\" brand.","body":"branding-context.ts drops 'Helios' + npm-scoped sentinels from platform_settings.appName; defaults are empty; _shared.ts footer wraps {{ appName }} / {{ orgLegalName }} in section blocks. seedSystemTemplates upserts code-owned rows on every boot so template-body fixes propagate.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/white-label-branding-rule.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"b2999a73-3196-4e17-815d-01aeed393810","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"hrm-job-responsibilities-thread","type":"added","scope":"hrm","summary":"Job responsibilities thread into HRM joining letter + contract bodies + employee detail Role card + candidate joining-pack \"Your role\".","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/hrm-job-responsibilities-thread.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"},{"id":"3c70cf95-9483-4c44-a126-dc3ec64761c4","releaseId":"996b4688-a9fd-4d06-9fce-67a53cdd058b","slug":"drizzle-migration-when-fix","type":"fixed","scope":"infra","summary":"Bump journal \"when\" timestamps so drizzle-kit picks up migrations 160-170 (chat channels regression).","body":"Migration timestamps must be monotonic — pre-fix, new migrations with older timestamps were silently skipped by drizzle-kit. Rule documented at memory feedback_drizzle_migration_timestamps.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-4-modules-ship-ready-2026-05-18/drizzle-migration-when-fix.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.776Z","updatedAt":"2026-05-23T21:15:56.776Z"}]},{"id":"7f53bfb6-8354-40c4-88a7-bb54461bd308","tag":"R+3","slug":"r-3-platform-and-auth-2026-05-11","version":null,"title":"Platform + auth — multi-domain, calendar, SaaS plans, Better-Auth hardening (passkeys, 2FA, SSO)","summary":"Platform week. Multi-domain / multi-surface routing landed (per-org many-domains × per-surface). Calendar module cross-module aggregator shipped. SaaS plans + subscriptions + announcements went live with /saas/* sub-nav. Better-Auth audit + hardening produced passkeys, 2FA (TOTP/backup/email), magic-link, OAuth (Google/GitHub/Microsoft/Apple/Discord), HaveIBeenPwned check, password-strength meter, cookie-cache, geo-aware new-device alerts, pluggable rate limiting. Payroll R60 added the payslip PDF pipeline.","status":"published","publishedAt":"2026-05-17T22:00:00.000Z","periodStartsAt":"2026-05-11T00:00:00.000Z","periodEndsAt":"2026-05-17T23:59:59.000Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["platform","auth","historical-backfill"],"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.350Z","entries":[{"id":"1e07a1f5-edc6-485a-a0b3-eb4bebe878d2","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"projects-data-panel-r88","type":"added","scope":"projects","summary":"Projects Data Panel (R88) — notes / url_link / entity_link / attachment / credential kinds + AES-256-GCM secrets + reveal action.","body":"Phases P1–P3. Encrypted secrets at rest with reveal-action audit. P4 physical assets + TipTap + entity picker deferred.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/projects-data-panel-r88.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"},{"id":"a6f8f11b-e596-47ab-888a-e11b960d81c1","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"calendar-module-initial","type":"added","scope":"calendar","summary":"Calendar module — cross-module aggregator + /calendar route (month/week/day/agenda) + Today's Highlights + Pending tasks widget.","body":"Initial ship aggregates events from HRM (leaves, shifts), Projects (due dates), Recruitment (interviews), Sales (meetings) into a single calendar surface. Realtime invalidation + external sync deferred.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/calendar-module-initial.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"},{"id":"77df3d6d-0702-4800-bab6-59df964da3b3","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"time-tracking-audit-drained","type":"added","scope":"hrm","summary":"HRM time-tracking — 10/10 audit items + Phase 2 follow-on (edit history, auto-approval, /hrm/team dashboard, 4 notification flows + crons).","body":"Edit history migration 0143_0144, auto-approval 0144_0145, at-risk weekly digest, coverage-symmetric rollup, leave overlay, bulk approve/reject. Phase 2: P1-5 leave intersection, batched crons, relaxation accumulator, cross-midnight pill, event-class doc, cross-page relaxation, extended-break signal (migration 0156_0157).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/time-tracking-audit-drained.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"},{"id":"f61897b4-c24e-434d-8a83-221ecbb5c9bf","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"payroll-r60-pdf","type":"added","scope":"payroll","summary":"Payroll R60 — payslip PDF end-to-end (template + render action + auto-render on finalize + download UI).","body":"60-action audit completed. Payslip PDF pipeline ships with templated layout, render action, auto-render on payslip finalize, download UI on the employee view.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/payroll-r60-pdf.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"},{"id":"38690fdc-bf47-49e9-99d4-c0f1bd5a1bf4","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"industry-template-expansion","type":"added","scope":"hrm","summary":"Industry-standard doc templates (R50) — offer/contract/NDA/joining-letter expanded to production-grade content.","body":"All region-specific text dynamic via mustache vars; INDUSTRY_DEFAULT_VARIABLES seeded in both renderers (recruitment + HRM).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/industry-template-expansion.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"},{"id":"68449c50-ce8e-4542-a745-765456dcc0d4","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"template-defaults-r46","type":"added","scope":"recruitment","summary":"Template defaults (R46) — is_default on all 6 recruitment + HRM template tables, set/clear-default actions, ★ Default UI pills.","body":"12 set/clear-default actions; selection-logic upgraded to prefer the org's default when available. Conditional matching deferred to Phase 3.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/template-defaults-r46.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"},{"id":"f72f13bc-b398-4148-9f7a-55867570fe3f","releaseId":"7f53bfb6-8354-40c4-88a7-bb54461bd308","slug":"ui-polish-r47","type":"changed","scope":"recruitment","summary":"UI polish R47 — careers list + apply skeletons, inline form validation, recruit-modal task validation, application status humanisation.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-3-platform-and-auth-2026-05-11/ui-polish-r47.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.337Z","updatedAt":"2026-05-23T21:15:56.337Z"}]},{"id":"b73aa282-9237-4bce-bc15-c70b3d751da5","tag":"R+2","slug":"r-2-polish-and-multichannel-2026-05-04","version":null,"title":"Polish + multi-channel — Slack-grade chat, AI providers, unified notifications, projects R9–R42","summary":"Chat reached Slack-grade unread state and rich presence (in_huddle / typing / active / away / offline). AI Provider Management Phases A0–A5.5 shipped — vendor-reported token counts plumb through to the cost ledger. Notifications module landed multi-channel (in-app + email + push) with 10 module migrations. Projects shipped 34 numbered polish rounds (R9–R42). Cross-module Phase 11 closed 5 long-tail gaps.","status":"published","publishedAt":"2026-05-10T20:00:00.000Z","periodStartsAt":"2026-05-04T00:00:00.000Z","periodEndsAt":"2026-05-10T23:59:59.000Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["polish","notifications","ai","historical-backfill"],"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.029Z","entries":[{"id":"bb2bbe88-2d7d-489b-8be1-94e5f7a8018d","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"chat-slack-grade-unread","type":"added","scope":"chat","summary":"Slack-grade unread state — per-user last-read marker, \"Jump to latest\" with unseen counter, channel + DM badges.","body":"Pre-this, the unread story was inconsistent across channels, DMs, and threads. The new model defines a single per-(user, channel) last-read marker that drives every UI surface uniformly. See docs/chat/UNREAD_INDICATOR_SPEC.md.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/chat-slack-grade-unread.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"e92d4853-672b-4b82-8d0c-262fcda762bd","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"chat-presence-v2","type":"added","scope":"chat","summary":"Rich availability indicator — in_huddle / typing / active / away / offline.","body":"Replaces the binary online/offline dot. Surface differentiation lets the team see who is in a meeting vs idle vs actively typing.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/chat-presence-v2.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"a3e1825f-013b-423d-871e-970e00af9963","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"ai-provider-management","type":"added","scope":"ai","summary":"AI Provider Management — connect Anthropic / OpenAI / Gemini per org, with vendor-reported token usage flowing into the cost ledger.","body":"Phases A0–A5.5 landed: provider connection wizard, routing rules per use-case, vendor adapters that surface real token counts (not 4-chars-per-token heuristic), cost dashboard with budget alerts. Heuristic stays as a fallback only.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/ai-provider-management.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"af46353c-742e-45a5-82be-66640c0bcb24","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"notifications-multi-channel","type":"added","scope":"notifications","summary":"Notifications module — in-app + email + push (sms reserved); matrix UI, dispatcher, Web Push, 10 module migrations.","body":"Phases 0–9: foundations, dispatcher, matrix UIs, Web Push subscription, 10 module-specific migrations (CRM / HRM / chat / etc), chat-digest, email-failure admin alert, DND quiet hours, PWA badge, unsubscribe links.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/notifications-multi-channel.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"67834d82-7740-4dc0-8d22-e4cff65da294","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"email-module-feature-complete","type":"added","scope":"email","summary":"System Email module — feature-complete vs spec.","body":"Outbound + inbound, MJML templates, bounces, complaints, suppression, deliverability, provider abstraction (Postmark / SES / Resend / Mailgun), per-org routing. The seeds-test cross-validates EMAIL_FLOWS ↔ SYSTEM_TEMPLATES on every CI run.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/email-module-feature-complete.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"50f136a0-83bd-460c-bd12-1df9a89b1706","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"recruitment-pipeline-engine","type":"added","scope":"recruitment","summary":"Recruitment Phases 0–3 — foundation, public-token infra, pipeline-stage automation engine, interview emails+ICS, offer PDF+accept.","body":"Public-token routes for candidate-facing surfaces (offer accept, document signing). Pipeline engine automates stage transitions based on configurable rules.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/recruitment-pipeline-engine.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"808e7d39-973e-4f42-a516-0d637fbc26e7","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"recruitment-queue-seven","type":"added","scope":"recruitment","summary":"Recruitment 7-item queue — pickers + HRM sync, comp period, public-link fixes, careers UX, settings polish, job-detail overhaul.","body":"Drained a 7-item product queue in one batch: HRM Department/Reports-To pickers wired through, compensation period normalised, public-link routes hardened, careers portal UX swept, per-org subdomain for careers.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/recruitment-queue-seven.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"62a33528-c41f-4328-85e7-c673261bbad1","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"dashboard-redesign","type":"added","scope":"workspace","summary":"Dashboard redesign — 5 role-tailored variants (root/owner/manager/employee/client), widget framework, notice board, reminders.","body":"Phases 0–6: widget framework, role-tailored variants, notice board with hrm_notices migration, HR policies tile, reminders, important tasks. All shipped with passing lint/typecheck/tests.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/dashboard-redesign.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"1e7059a2-a83a-48ae-894a-95991f0005a7","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"projects-polish-r9-r42","type":"changed","scope":"projects","summary":"Projects — 34 numbered polish rounds R9–R42 (saved views codec, custom fields, drag-to-reorder, manual sort, inline editors).","body":"Highlights: R10 cycle close summary + project + milestone status notifications, R11 custom field admin UI + display, R12 inline assignee / due-date editors, R13 active-filter pills, R14 Undo toasts, R15 subtask done toggle + progress, R16 task-count progress bars, R17–R20 multi-axis filters + manual sort, R21 URL search-param persistence, R22 minimalist filter toolbar (16 sub-rounds), R23 saved-view picker discoverability, R24 sticky title, R25 deep-link via ?task=, R26 Tasks promoted to primary section, R27 deep alignment across analytics/milestones/cycles, R28–R42 layout polish across every projects route.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/projects-polish-r9-r42.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"dbea5b7d-76b8-489c-ba74-619d7fd418f6","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"projects-bulk-bar","type":"added","scope":"projects","summary":"Projects multi-select + floating bulk-action toolbar (assign / label / cycle / milestone / set position).","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/projects-bulk-bar.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"16f76d6f-3731-4945-9bd7-60870f95f52d","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"projects-spa-perf","type":"performance","scope":"projects","summary":"Full SPA navigation + skip duplicate loader fetches + tuned QueryClient defaults — projects feels native-fast.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/projects-spa-perf.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"2fc58fc5-3e55-40c0-9836-48a58230614d","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"sales-overview-aligned","type":"changed","scope":"sales","summary":"Sales R40–R42 — overview + clients list + remaining routes aligned to module standard.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/sales-overview-aligned.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"1cd77f9e-c07f-4062-ba70-3e5380b84392","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"chat-attachment-redesign","type":"changed","scope":"chat","summary":"Attachment row redesign — typed file chips + image grid + soft-shadow channel header on scroll.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/chat-attachment-redesign.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"9e18c97c-93cd-4854-8b35-146bb5654f2c","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"chat-composer-affordances","type":"added","scope":"chat","summary":"Composer affordances — per-scope draft persistence, autofocus on channel switch, full-width comfortable rails, DM-specific placeholder.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/chat-composer-affordances.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"c1fac4b7-b666-435b-a0e8-9dcc7f2eb6db","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"chat-notification-bell","type":"added","scope":"chat","summary":"Notification bell dropdown + realtime toast + mark-single-read on click.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/chat-notification-bell.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"},{"id":"a8bd9829-bfa5-4efa-bec4-497bd0d2e2ac","releaseId":"b73aa282-9237-4bce-bc15-c70b3d751da5","slug":"ui-viewport-zoom-fix","type":"fixed","scope":"ui","summary":"Swap viewport zoom for font-size bump (sticky regression fix).","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-2-polish-and-multichannel-2026-05-04/ui-viewport-zoom-fix.md","internalOnly":false,"createdAt":"2026-05-23T21:15:56.015Z","updatedAt":"2026-05-23T21:15:56.015Z"}]},{"id":"5ec87f8d-3c42-4479-b313-10ca3a197392","tag":"R+1","slug":"r-1-foundations-2026-04-27","version":null,"title":"Foundations — chat module, projects kanban, single-file deploy","summary":"First week of substantive product work. Real-time chat module landed with threads, presence, channel categories, slash commands, pinned messages, embeddings + hybrid search. Projects kanban shipped through Phases 1–8. Deploy story consolidated into a single compose stack pulling from GHCR.","status":"published","publishedAt":"2026-05-03T20:00:00.000Z","periodStartsAt":"2026-04-27T00:00:00.000Z","periodEndsAt":"2026-05-03T23:59:59.000Z","coverImageUrl":null,"notifyOnPublish":false,"tags":["foundations","historical-backfill"],"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.259Z","entries":[{"id":"b3306fb5-baa3-4788-aaa7-61f94604b870","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-module-thread-primitive","type":"added","scope":"chat","summary":"Chat module shipped — threads, edit/delete, mention persistence, FTS reconciliation, realtime contract test.","body":"Phases 1–3 of the chat thread primitive landed: message-thread linking, uniform edit/delete/restore semantics, mention persistence with @mention popovers, full-text-search reconciliation, and a realtime contract test that catches divergence between the WebSocket fan-out and the database. 60+ tests cover the new surface.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-module-thread-primitive.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"a142ccf9-6fee-4fe8-bb69-fde0bb8f31de","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-pgvector-search","type":"added","scope":"chat","summary":"Hybrid search (FTS + pgvector embeddings) for chat with OpenAI embedding adapter + backfill cron.","body":"ADR 0010 chose pgvector over Pinecone/Weaviate. The chat search now combines lexical FTS with vector similarity, ranked together. An OpenAI embedding adapter is bundled with a backfill cron that ingests historical messages.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-pgvector-search.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"ec2c7d8e-68b1-4e13-9018-9259f03cbde6","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-presence-slash-commands","type":"added","scope":"chat","summary":"Live presence, slash commands, user hover-cards, mention popovers, and DM/topic primitives.","body":"Top-bar online count, presence dots in DMs and the member list, slash command parser (/topic, /leave, /huddle, …), user hover-card on mention click, and primitive DM open from any message.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-presence-slash-commands.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"a13b4e96-530d-4563-86f7-a7fc09e646a3","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-channel-categories","type":"added","scope":"chat","summary":"Org-wide channel categories with drag-and-drop between groups + sidebar reorder.","body":"Org admins define categories (Sales, Engineering, Random, …); members drag channels between them. Per-user persisted ordering. Starred-channel pinned section. New-message dividers and unread realtime.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-channel-categories.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"4871121a-f066-477b-bb31-12c88b9ee2e1","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-rich-toolbox","type":"added","scope":"chat","summary":"Rich-text composer, inline edit, threads popover, pinned messages, OG-style URL preview cards, image lightbox.","body":"Tiptap composer with rich-text round-trip; ↑ to edit last own message; per-channel pinned drawer; smart URL previews with OG metadata; full-screen lightbox with prev/next nav.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-rich-toolbox.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"b19334ca-0864-4c13-af40-f358ea8dc6db","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-scheduling-bookmarks","type":"added","scope":"chat","summary":"Message scheduling (\"send later\"), saved messages / bookmarks, mark-as-unread, per-channel mute.","body":"Schedule a message for later with preset windows; bookmark any message and review from a \"Saved\" popover; per-channel mute (1h / 8h / 1d / 1w / forever).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-scheduling-bookmarks.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"68da0a5b-1d6e-426f-9627-51172764413f","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-broadcast-mentions","type":"added","scope":"chat","summary":"@channel and @here broadcast mentions + per-channel @-mention badge in the sidebar.","body":"Broadcast mention rules respect mute state; sidebar lights up only when you're actually addressed (DM, mention, broadcast you opted in to).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-broadcast-mentions.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"57ba39b1-f789-499d-a0ec-f41c10dbc53e","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-helios-ai-mention","type":"added","scope":"chat","summary":"@helios mention triggers AI agent with \"composing…\" indicator visible to all channel members.","body":"Mentioning @helios in a chat fires the AI agent; everyone in the channel sees a \"Helios AI is composing…\" indicator until the response posts.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-helios-ai-mention.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"5747a288-3d5b-45db-8696-5fb0e6605cc4","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-seen-by-receipts","type":"added","scope":"chat","summary":"\"Seen by N\" read receipts on the actor's last channel message + DM read ticks.","body":"Each user's most recent message shows aggregated read state. DMs show per-message ticks (sent / read).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-seen-by-receipts.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"1028c800-6ba0-48a2-9d76-8f77bb237795","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-dark-mode-v2","type":"added","scope":"chat","summary":"Refined dark mode v2 — proper palette, not channel-inversion.","body":"Reworked dark mode from the ground up — semantic surface tokens, not luminance flip. Tested against every chat surface (composer, popover, modals, lightbox).","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-dark-mode-v2.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"d499d276-b3e2-4608-9891-01aae2cb29af","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"projects-kanban-phases-1-8","type":"added","scope":"projects","summary":"Projects kanban — Phases 1–8: top-accent cards, tinted columns, drag-lift polish, multi-select bulk bar.","body":"Eight phases of the kanban experience: visual hierarchy reset, view switcher, watcher + comment chips on cards, multi-select with floating bulk-action toolbar, drag-lift animation polish. The bulk bar covers Assign / Label / Cycle / Milestone / Set position.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/projects-kanban-phases-1-8.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"e1b905df-8c5e-4b13-af63-7a9d2e359735","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"projects-task-detail-sheet","type":"added","scope":"projects","summary":"TaskDetailSheet — tabbed (Subtasks / Comments / Attachments), prev/next navigation, custom field editor.","body":"Single source of truth for task detail. Inline custom-field editing, subtask done-toggle with progress bar, prev/next sibling navigation, sticky title, URL deep-link via ?task=.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/projects-task-detail-sheet.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"135f8ce9-6b05-4b7e-8d34-973f7eed3ae5","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"projects-templates-engine","type":"added","scope":"projects","summary":"Project templates — materialise milestones + nested tasks at instantiation.","body":"Template builder reworked as a milestone→task tree. Instantiating a template now materialises the milestone hierarchy with proper parent links — no manual rebuild required.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/projects-templates-engine.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"f1a12510-58ce-4cd4-b194-5ed39f306961","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"projects-saved-views","type":"added","scope":"projects","summary":"Saved views with URL codec round-trip — team / cycle / milestone / sortDir all persist.","body":"URL is the source of truth for list state. Saved views encode the full filter + sort + group-by + collapsed-group set in a single share-able link.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/projects-saved-views.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"f6c05c27-bd82-43d7-ad2a-b5eb354b0c16","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-search-improvements","type":"changed","scope":"chat","summary":"Search modal — query highlight, keyboard hints, jump-to-message with transient highlight, in:#channel filter syntax.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-search-improvements.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"d728884d-a7de-4f66-9bf8-a53637b4a4a5","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"chat-realtime-reliability","type":"fixed","scope":"chat","summary":"Realtime notification + unread badge reliability — polling fallback, broader listener registration, missing event emissions.","body":"Pre-fix, both flows were emitting rows without firing the notificationCreated event; the badge and sidebar only updated on a hard reload. Fixed by emitting on every write path and adding a polling fallback for missed events.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/chat-realtime-reliability.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"b1c0e197-b912-4c96-b87d-ec624f174966","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"deploy-single-file-compose","type":"added","scope":"infra","summary":"Single-file Docker Compose stack pulling production images from GHCR.","body":"One `docker compose up` is all an aaPanel-class VPS needs. Auto-deploy on `git push` is opt-in via a repo variable. Documented step-by-step runbook for aaPanel; bootstrap-simple.sh authenticates raw fetches via GITHUB_TOKEN.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/deploy-single-file-compose.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"20747bba-c603-4d46-9cb1-3571a3df6c56","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"deploy-minio-bundled","type":"added","scope":"infra","summary":"MinIO bundled into the prod compose stack with split server-side / public-URL endpoints.","body":"Lets a self-hosted deployment avoid managed S3 + CloudFront. The split endpoints fix presigned-URL signing under a reverse proxy.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/deploy-minio-bundled.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"8574e096-f720-4740-ada9-b975991ed5fd","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"deploy-livekit-sfu","type":"added","scope":"infra","summary":"Self-hosted shared LiveKit SFU compose stack for chat huddles.","body":"Brings audio/video huddles into the same single-stack deployment story. No reliance on a managed SFU.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/deploy-livekit-sfu.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"e22ca8b8-3f1f-4a96-ba26-b3ff1bc6ccaf","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"platform-i18n-autoseed","type":"added","scope":"i18n","summary":"Auto-seed i18n catalogue on every migrate run.","body":"New strings land in the catalogue without a manual seed command. Convert readonly entries to plain array + dump Zod issues for safer authoring.","breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/platform-i18n-autoseed.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"},{"id":"534f6e2f-db61-466a-b2c9-520910b12c7d","releaseId":"5ec87f8d-3c42-4479-b313-10ca3a197392","slug":"ci-typecheck-heap","type":"fixed","scope":"infra","summary":"Bump Node heap to 6 GB for typecheck; normalize line endings in lint; demote a11y to warnings.","body":null,"breaking":false,"migrationNotes":null,"prNumber":null,"commitSha":null,"issueRef":null,"authors":["helios-team"],"sortOrder":"500","tags":[],"sourceKind":"manual","sourcePath":".changelog/published/r-1-foundations-2026-04-27/ci-typecheck-heap.md","internalOnly":false,"createdAt":"2026-05-23T21:15:55.230Z","updatedAt":"2026-05-23T21:15:55.230Z"}]}]}