mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 19:32:10 +00:00
8aa87f2f4cd45837843c11e9536218355dcbe9b6
1131 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8aa87f2f4c | feat(web): render the archived-source provenance banner (TASK-2355) | ||
|
|
5d327c96d1 | feat(web): add the cross-workspace copy dialog (TASK-2355) | ||
|
|
bbb21ef23d | feat(web): add copy/preflight API client methods (TASK-2355) | ||
|
|
d9d96b85c9 | refactor(store): delete two unused item-workspace-move accessors (TASK-2374) | ||
|
|
3bbd326857 | test(store): make the copy concurrency and attachment assertions bite (TASK-2372) | ||
|
|
c6ebe5a3e3 |
refactor(store): unify the collection column list and scan (TASK-2368)
Three accessors read a full collection row and each carried a verbatim copy of the same 15-column projection and scan/hydration block: GetCollection, GetCollectionAnyState, and the transactional getCollectionInWorkspaceTx used by the cross-workspace copy. A column added to the model had to be added in three places, and the copy path drifted silently if only GetCollection was updated. Extract collectionColumns plus scanCollectionRow, parameterized over rowQueryer (the uniqueSlugQ / validateAssignmentScopeQ pattern from TASK-2362) so the same read runs against *sql.DB or inside a caller's *sql.Tx. Each accessor's full statement is assembled from constants, so the WHERE predicate is the only per-caller difference, the SQL is built at compile time rather than per call, and no runtime-assembled fragment is ever handed to s.q. Preserved deliberately: s.q placeholder rewriting (applied once, inside the helper, so no call site can skip it); nil-on-sql.ErrNoRows at every accessor -- the helper returns real errors unwrapped so each keeps its own distinct prefix; the transactional lookup stays workspace-scoped and active-only, which is the security boundary that makes a foreign collection a not-found rather than a cross-workspace write. lockCollectionRows is untouched: its SELECT id ... FOR UPDATE is a locking primitive that duplicates nothing, and its sorted acquisition is load-bearing. ListCollections is deliberately left out and documented as such: it is an aggregate multi-row query with aliased columns, a trailing COUNT and no deleted_at, so sharing a projection would need a second count-aware scanner and would reshape a hot query for no correctness gain. TestCollectionAccessorsShareOneHydration pins all three to one hydration. Every scanned column except deleted_at is asserted against a literal, distinct value rather than against another accessor's output, since cross-accessor equality alone cannot catch a mutation in the shared projection; created_at and updated_at are set to different instants so transposing them fails, and deleted_at is pinned by the soft-delete branch, the only state in which it is non-nil. Verified by mutation: a transposed slug/prefix projection, a transposed created_at/updated_at projection, a dropped workspace scope on the transactional read, a flattened deleted-state predicate, and a miss turned into an error each fail the test. |
||
|
|
98c638fc86 | refactor(server): extract resolveAuthorizedCopy shared by preflight and copy (TASK-2370) | ||
|
|
70f9fefeee |
Merge pull request #1048 from PerpetualSoftware/feat/cross-workspace-item-copy
feat: cross-workspace item copy (PLAN-2357) |
||
|
|
c783d36a13 |
fix(store): make migration 077 constraint-equivalent to 055 per final review
Postgres' BOOLEAN admits exactly two values; SQLite's bare INTEGER admits any. A stray 2 would scan as true through BoolToInt while the partial index the moved-to lookup uses is WHERE archived_source = 1 — a row that reads as a move but is invisible to the query that finds moves, which the Postgres schema cannot represent. Add the CHECK, and make id NOT NULL explicit since SQLite does not imply it for a TEXT PRIMARY KEY. Migration 077 is unreleased, so amending it in place is safe. The test is mutation-verified. Its first draft was NOT: it used placeholder ids and passed against a schema with no CHECK at all, because the foreign keys rejected the insert before the constraint under test was reached. It now uses real fixture rows and asserts the same row inserts cleanly with archived_source = 1. Found by the final full-diff Codex pass over PLAN-2357, data-at-rest angle. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
cfc83e8c57 |
fix(server): report partial and legacy relationships in the copy dry-run (TASK-2369)
Two ways the cross-workspace copy preflight told a user "nothing to lose" when there was, both violations of PLAN-2357 DR-17's "none of this may be silent". P1 — the five relationship counters are ACL-filtered by the caller's collection visibility (correct, and TASK-2364 chose it deliberately), but "none" and "none that you can see" rendered identically. A caller with edit rights on the source and none on its relatives could read `children_orphaned: false` and run a MOVE believing nothing was stranded, while hidden children were orphaned in place. The filtering stays; the uncertainty is now surfaced. Every point that drops a relationship for visibility reasons sets a new `warnings.relationships_partial` boolean. It is a BARE BOOLEAN by design: how many are hidden, of what type and in which collection are exactly the facts the filter exists to withhold, and a marker that varied with the hidden count would reinstate the leak DR-10a, DR-10b and the moved-to pointer each closed separately. A negative test asserts byte equality of the whole warnings block across two workspaces that differ only in how much is hidden. It is false for an unrestricted caller AND for a restricted caller with nothing hidden, so the common case renders exactly as it did before. P2 — a child reachable only by a lone legacy `plan` edge was invisible to GetChildItems (its join is restricted to store.ChildLinkTypes), so an incoming `plan` relationship reported `child_count: 0` / `children_orphaned: false` even though archiving the source strands it. The link scan now folds such an edge into the child set, deduplicated against the two mechanisms already covered and subject to the same visibility, liveness and workspace guards. The outgoing direction (the item's own parent) already reported correctly. The mutating copy reports no relationship counters at all (ItemCopyResultWarnings is deliberately narrower), so there is nothing for assertPreflightMatchesCopy to disagree about. CLI renders the qualifier on the five affected lines plus a plain-language explanation; TS types carry the field for Phase 3's dialog. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
66fa464699 |
fix(store): distinguish SQLite lock timeout from a real deadlock per final review
isDeadlockError matched SQLite's "database is locked" alongside Postgres' 40P01, and the rollback path logged both as deadlock=true at ERROR. But SQLite is single-writer with a 30-second busy timeout, so "database is locked" is an expected saturation mode under burst load — it says the box is busy. A 40P01 says DR-9's lock ordering, which is meant to make deadlock impossible, is wrong. Reporting both identically left an operator unable to tell a lock-ordering bug from ordinary load, defeating the only signal this log exists to carry. Split the predicates and add lock_timeout to the log line. Classification test is mutation-verified: reintroducing the conflation fails it. Found by the final full-diff Codex pass over PLAN-2357, operability angle. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
2b9da9412b |
fix(store): classify unique violations as expected copy rejections per final review
The store logged a unique-constraint violation as an unexpected rollback incident while the HTTP layer mapped the same error to a caller-facing 409. A workspace-unique field colliding in the destination — a playbook's invocation_slug, say — reaches this on ordinary input, so every routine 409 fired an operator warning and buried the deadlock signal the log exists to surface. Found by the final full-diff Codex pass over PLAN-2357 (P2: two commits classified the same error two ways). Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
f15ba86db0 |
docs(server): correct the cross-workspace authz re-check contract per final review
The helper's doc mandated that a mutating caller "re-apply the check" inside its write transaction. Its only mutating consumer deliberately does not, and is right not to: these functions read through s.store rather than the caller's tx, so under READ COMMITTED the re-check would judge locked resources against authorization state read at several unsynchronised moments — reading as a write-time guarantee while providing none. State what a mutating caller actually owes (re-read the authorized resource IDENTITY in-tx and refuse if it moved) and what it must not do, so the contract and copyResourceInvariantPreCheck no longer disagree. Found by the final full-diff Codex pass over PLAN-2357 (P1: a documented write-time guard was in fact a TOCTOU check). Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
1e48a7a1dd |
feat(cli): add pad item copy for cross-workspace copy and move (TASK-2366)
Wraps PLAN-2357's two endpoints behind one command:
pad item copy <ref> --to-workspace <slug> --collection <slug>
[--dry-run] [--archive-source] [--field key=value ...]
--dry-run renders the preflight's three contract buckets (carried /
dropped / needs_value) and DR-15's full warning set. Every bucket header
and every warning line prints unconditionally, zeros and empties
included: omitting a zero would make "no attachments" indistinguishable
from "this CLI does not report attachments", and DR-17's whole point is
that none of it is silent. Schema-supplied strings are escaped and list
members quoted, so a comma or newline in an option value cannot forge an
entry or a row.
--format json emits the endpoint's own response. json.Indent is a lexical
transform, so key order, unmodelled fields and int64 precision all
survive; the bytes are never round-tripped through a Go value.
DR-13, the no-retry obligation. There is no idempotency key, so a blind
re-run duplicates the item. Four mechanisms, each with a test:
1. the mutating copy runs on its own *http.Client AND its own
transport. The transport half is the one that matters: retry in Go
is almost always a RoundTripper wrapper, which a merely-dedicated
http.Client would inherit. A plain *http.Transport is cloned so
proxy/TLS config carries; a wrapper is not used at all;
2. its body is hidden behind an opaque reader, leaving Request.GetBody
nil so net/http's own nothing-written replay cannot fire;
3. redirects are refused rather than followed with the POST body;
4. failures are classified into three exclusive outcomes, because each
licenses a different thing to say. UNKNOWN (transport failure, 500
copy_failed) sends the user to check the destination and never
suggests a retry. COMMITTED-BUT-UNREPORTED (a 2xx whose body could
not be read or decoded) exits ZERO -- a non-zero exit would tell a
script the copy did not happen, which is the DR-13 duplicate
arrived at through the reporting layer. A 4xx is a refusal made
before any write and passes through plainly.
The same asymmetry governs stdout: a write failure on the dry run is an
error (nothing happened), while a write failure after the copy committed
goes to stderr and leaves the exit code at 0.
Refuse to guess. The preflight always runs first (it is read-only), and a
non-empty needs_value refuses before any mutating request, naming each
field and the exact --field flags to add. Mirrors the web dialog's
disabled confirm rather than round-tripping the user into an error they
could have been shown.
--field values are typed against the DESTINATION collection's schema, so
a number lands as a number. A malformed --field is a hard error here
rather than the silent skip `pad item create` does: this command's
contract is "you were told what to supply", and dropping a supplied value
would make the refusal a lie.
The response types in internal/cli mirror internal/server's. That is a
layering choice, not a cycle -- nothing in server imports cli, and the
mirror test imports server freely. It follows the posture already
recorded in internal/cli/bootstrap.go: this package is the HTTP client
and does not depend on the server package. An external cli_test package
walks both response shapes and fails on any JSON contract drift.
MCP is deliberately untouched: no pad_item.action: copy, and
ToolSurfaceVersion stays 0.15.
|
||
|
|
f8ff5742e5 |
feat(server): add cross-workspace copy endpoint with post-commit fanout (TASK-2365)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
01d640978c |
feat(server): add cross-workspace copy dry-run preflight endpoint (TASK-2364)
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
0fad869a28 |
feat(store): add CopyItemAcrossWorkspaces atomic orchestration (TASK-2363)
PLAN-2357 DR-9 / DR-9a / DR-11 / DR-12 / DR-14 / DR-16 / DR-17. One
store operation, one transaction: create in B, clone attachments,
archive A on a move, write provenance.
Lock order (the whole point of DR-9):
1. Both workspaces' advisory locks, sorted and deduplicated by the
hashtext LOCK KEY — sorting the ID strings does not order their
hashes, so two opposing movers could still deadlock.
2. Both collection rows FOR UPDATE, sorted by collection ID —
MigrateFields consumes both schemas.
3. Source item re-read under those locks; that snapshot is copied.
Both primitives are dialect-gated: FOR UPDATE is a syntax error on
SQLite, where BEGIN IMMEDIATE already serializes writers.
Pipeline: migrate -> overrides -> validate (DR-12: MigrateFields'
errors are stale once an override lands) -> quota -> PlanAttachmentCopy
INSIDE the tx -> rewrite content AND fields via the plan's IDMap ->
create in B -> attachment rows (originals before variants, item_id set
from the outset, uploaded_by = the actor) -> archive A -> provenance.
Seq (DR-14): B always advances; A advances only on ArchiveSource, and
a plain copy leaves A completely untouched. Quota (DR-16) runs inside
the transaction after the destination lock so two concurrent copies
cannot jointly exceed the cap.
Cross-backend attachment copies are REFUSED in v1
(ErrCopyCrossBackendAttachments): the store has no AttachmentStore
handle, and a byte transfer under both workspaces' locks would block
every writer in both workspaces on unbounded I/O with no rollback.
Supporting changes:
- CreateAttachmentTx: tx-taking insert (CreateAttachment is
self-committing), sharing one body with the pool form.
- CheckLimitTx: the feature COUNT reads through the caller's tx.
- createItemTxWithID: createItemTx with a caller-supplied id, so the
destination item id exists before the attachment plan is built.
Tests: creation parity, seq on both sides, DR-12 ordering, DR-8/DR-17
scrubs, attachment clone + rewrite (including refs in code fences),
DR-11a unresolvable refs, rollback at all four stages, quota. Postgres
only: opposing A->B / B->A copies do not deadlock, concurrent copies
cannot jointly exceed the cap, and colliding hashtext keys take one
lock. All three verified falsifiable by mutating the production code.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
|
||
|
|
60dd3e1a37 |
feat(store): add attachment resolution planner for cross-workspace copy (TASK-2354)
Implements PLAN-2357 DR-11 / DR-11a. PlanAttachmentCopy takes the copied content plus the FINAL destination fields and returns the old->new attachment UUID map, the rows to create (originals followed by their variants, parent_id remapped), the byte total, and the unresolvable-ref list. It writes nothing, takes no *sql.Tx, and is shared by the copy orchestration and the dry-run endpoint so their numbers cannot drift. DR-11a: every resolution is scoped to workspace_id = A AND deleted_at IS NULL, and the parent/variant traversal carries the identical scope. The reference set comes from user-controlled content, so an unscoped lookup would let a user clone another workspace's blob into a workspace they control, bypassing the download handler's workspace check. Refs that resolve to nothing under that scope -- dangling, soft-deleted, or foreign -- are never cloned and never fatal: they get no map entry, so the rewrite preserves the literal text and the copy renders exactly as broken as the source did. A cross-backend row emits an empty storage_key with the source key in SourceStorageKey, so the plan never contains a key the target backend cannot resolve. CreateAttachment now rejects an empty storage_key, which turns that contract into an enforced invariant: an orchestration that skips the Get/Put byte transfer fails at insert rather than creating a live attachment that 404s on download. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
dbede59edf |
refactor(store): extract tx-taking item creation helper (TASK-2362)
Implements PLAN-2357 DR-9a. CreateItem opens and commits its own transaction, so the cross-workspace copy path (create in B + attachment remap + provenance row + optional source archive, all atomic) cannot call it. A raw in-tx `INSERT INTO items` in its place would silently break version history, wiki-links, reporting, delta sync and slug uniqueness -- none of which fail loudly. Extracted, not duplicated, and CreateItem now goes through the same function so the two paths cannot drift: - `insertItemTx` is the write half, lifted verbatim out of the old tryCreateItem body: the items INSERT (item_number, workspace seq, content-flush watermarks), the initial item_versions row, wiki-link indexing + broken-title resolution, and the create-time status_transitions row. - `createItemTx(tx, workspaceID, collectionID, input) (*models.Item, error)` wraps it with the rest of CreateItem's pipeline -- defaults, assignment-scope validation, workspace-scoped unique slug allocation -- inside a caller-owned transaction. It returns the item read back in-tx so an orchestrator can consume its slug / item_number / seq (DR-14 fanout) without a post-COMMIT round-trip. - `tryCreateItem` is now a BEGIN/COMMIT wrapper around createItemTx, and CreateItem is the retry loop around that. - `uniqueSlug` and `validateAssignmentScope` gained rowQueryer- parameterized forms (`uniqueSlugQ` / `validateAssignmentScopeQ`) so both can run on the caller's transaction. The *sql.DB entry points delegate to them; behaviour is unchanged. Content must already be final: wiki-link indexing and the first version row are written from input.Content as given, so callers doing DR-11 attachment-ref rewriting must rewrite BEFORE calling. Trust boundary is documented on the function. collectionID/workspaceID consistency and ParentID scope stay the caller's job, matching the pre-extraction tryCreateItem -- DR-9 has the orchestrator re-read and row-lock both collections in-tx, so a check here would be a second, weaker read of an already-pinned row. Assignee and agent role ARE validated, as in CreateItem. No internal retry on unique violation: a failed statement poisons the caller's transaction and an internal retry would need a savepoint the caller can't see. Fixes a latent slug race in CreateItem along the way. It used to allocate the slug ONCE, outside the transaction, and re-submit that stale value on every retry -- so two concurrent creates of the same title had the loser burn all ten attempts on a slug the winner had already committed and then fail with a unique-constraint error. Slug allocation now happens inside the transaction under the workspace advisory lock, so each attempt sees the previous scan's outcome. Two Postgres-falsifiable concurrency tests cover it (createItemTx-only and mixed CreateItem + createItemTx). 24 tests: one per DR-9a parity-checklist line, a rollback test asserting no item / version / wiki-link / status transition / seq advance survives, an in-tx-visibility test, a slug-collision test, and the two concurrency tests. Every parity assertion verified falsifiable by mutating the production code. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
1eb1c9eda6 |
feat(server): expose ACL-gated moved-to pointer on item GET (TASK-2359)
An item MOVED to another workspace — copied, then archived — can now say where it went. GET on a single item gains an optional `moved_to` block naming each destination in displayable terms (workspace slug + item ref + title + collection slug), so a consumer can render a link without a second call. No HTTP redirect, no resolver change. The ACL gate is the point. A destination is revealed only after the caller independently passes AuthorizeCrossWorkspaceRead (TASK-2358) with an ITEM scope on the destination item itself. Workspace-level access is not sufficient: a restricted member of the destination workspace, or a guest holding one unrelated item grant there, has a role in that workspace while having no right to the copied item's collection. A caller who fails that check sees NO hint a destination exists. The key is omitted entirely — not a null, not an empty array, not a boolean — so the response is byte-identical to an archived item with no move record at all. A structurally distinguishable response is itself the leak. Restore decision: the block is OMITTED for a non-archived source. Restoring a moved-out source leaves two live items with the same content in two workspaces, which is legitimate, but at that instant the source has not moved anywhere and the response must stop asserting that it did. Past-tense provenance is the back-pointer question and applies equally to plain copies, which this field must never claim as moves. Also honored: DR-2a (only archived_source rows feed the pointer; plain copies are back-pointer material only), per-destination filtering over the forward lookup's SET with no short-circuit on the first hit or first denial, newest-first ordering, a scan bound on the per-GET authorization cost, and deliberate isolation of the hand-rolled public share-link DTO — pinned by an explicit negative test that freezes its key set. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
5804c80146 | feat(server): add cross-workspace authorization helper (TASK-2358) | ||
|
|
bf14c1168a |
feat(store): add item_workspace_moves provenance table (TASK-2356)
Phase 1 of PLAN-2357. Durable record of "this item was copied/moved from workspace A to workspace B", backing the forward redirect (TASK-2359) and the destination's back-pointer. Implements DR-2 / DR-2a. Paired, dual-dialect, forward-only migrations (migrations/077 + pgmigrations/055). archived_source distinguishes a move from a plain copy (INTEGER on SQLite, BOOLEAN on Postgres, written through dialect.BoolToInt). source_seq is a NULLABLE per-source move ordinal that exists solely so two moves inside the same second are orderable — created_at is second-precision RFC3339, so archive -> restore -> move again would otherwise resolve to an arbitrary destination. Partial index (source_item_id, source_seq DESC) WHERE archived_source, deliberately NOT unique: restore-then-move-again legitimately repeats. The back direction IS uniquely indexed — a destination item is created by exactly one copy, in the same transaction that writes its provenance row, so a duplicate there would silently change which source the back-pointer names. Cascade is asymmetric on purpose, inverting item_collection_moves: the archived source is precisely the row whose pointer must survive, so source_item_id carries no FK at all; target_item_id cascades, because a pointer at a vanished destination is worse than no pointer. Store accessors: a tx-taking insert helper (no self-committing variant — the row must land in the copy transaction), a forward lookup returning a SET newest-first, and a back lookup. The insert rejects an archived row with no seq and a copy row with one, so DR-2a's ordering invariant is enforced at the write boundary rather than assumed. NULL ordering is normalized with COALESCE because SQLite and Postgres disagree on DESC NULL placement. Also wires workspace purge, which the two-workspace shape requires: both workspace columns are RESTRICT references, so a purge clearing only one direction would fail outright when the purged workspace sits on the other end. Tests cover insert-in-tx, forward lookup with multiple destinations ordered newest-first and scoped to one source, back lookup, rollback leaving no row, and both DR-2a criteria. The ordering and scoping tests use fixed row IDs whose lexical order contradicts the expected answer, so deleting the ordering term or the WHERE clause under test fails them on every run rather than half the time; verified by mutating the production query. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
cdb495d400 |
chore(deps): bump google.golang.org/grpc to v1.82.1 for GO-2026-6061 (BUG-2361) (#1050)
grpc v1.79.3 is affected by GO-2026-6061 (xDS RBAC authorization engine and HTTP/2 transport server). It reaches us indirectly via internal/oauth -> ory/fosite -> ory/x/otelx -> otlptracehttp -> grpc. The bump pulls otel v1.42.0 -> v1.43.0 as grpc's required floor, which re-exposes GO-2026-5158 (uncapped baggage header parsing). Per the otel lockstep policy, otel/metric/trace/sdk all move together to v1.44.0, which clears it. make vuln is now completely clean (0 call-reachable vulnerabilities). Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
fd7c77c665 |
fix(web): collapse the mobile "Live" badge into the action bar (IDEA-2297) (#1038)
* fix(web): collapse the mobile "Live" badge into the action bar (IDEA-2297) At <=768px the collection page hid the h1 (the name lives in MobileContextBar) but left the SSE status badge in .title-group, so that row rendered a full line containing nothing but "* Live". The badge now has a mobile mount at the trailing edge of .header-actions, collapsed to the coloured dot alone, and .title-group drops out of layout entirely -- a 0-height flex item still collected .title-row's 12px column gap, which was the last of the wasted row. Desktop is unchanged: the labelled badge stays beside the title. Compact mode CLIPS the label rather than removing it. The span is a role="status" live region and live regions announce on text-content change, so an aria-label-only element wouldn't reliably announce a drop to "Offline". Also gives the action bar one control height (IDEA-2297's second half). The row mixed four: the quick-actions trigger ~22px, New ~24px (no border), the view dropdown ~26px, icon buttons 28px. All are 28px now. The trigger is normalised in the page rather than in QuickActionsMenu because ItemDetail's .meta-actions band sizes the same trigger to its own padding-based metrics; a height baked into the shared component would fight it. Same override shape and specificity reasoning that band already documents -- the child's scoped .trigger-btn.svelte-<hash> is (0,2,0), so a bare :global(.trigger-btn) would tie and be settled by cross-file source order. Mobile gaps go 12px -> 4px. The six controls total ~255px, so the dot's 12px inset didn't fit on one line at 360px (a common Android width) and wrapped, re-creating the row this change removes. Only the gaps shrink; the controls stay 28px, so touch targets are untouched. Verified in the browser against the installed binary: single row with a 12px inset at 430/390/375/360 (wraps at 340, as before); desktop badge still in .title-group with its label visible and aria-label="Live updates: Live" intact on both breakpoints; no horizontal overflow. npm run check clean, 490 web unit tests pass. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT * fix(web): don't convey mobile SSE state by colour alone (IDEA-2297) Codex review of #1038: with the label clipped in compact mode, hue was the only thing separating Live from Offline -- colour-alone conveyance (WCAG 1.4.1), and red/green is the exact pair dichromatic vision collapses. Healthy is now a FILLED dot and every unhealthy state is a hollow ring, so the distinction that matters ("is the stream up?") is carried by shape. Reconnecting stays separated from Offline by its pulse, and by hue for anyone running reduced-motion. Scoped to compact mode -- the labelled desktop variant already names the state in words. Verified by forcing each status class onto the live badge and reading computed styles at 390px: connected is a filled green 8px dot (border-width 0), reconnecting/disconnected/unauthorized are transparent with a 2px currentColor ring in their own hue, all 8px. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT * fix(web): separate reconnecting from offline without motion (IDEA-2297) Codex re-review of #1038: the hollow ring told Live apart from the unhealthy states, but Reconnecting leaned on its pulse to separate itself from Offline -- and the pulse is switched off under prefers-reduced-motion, leaving amber-vs-red as the only difference for those users. Reconnecting now takes a dashed ring. Three states, three shapes -- filled, dashed ring, solid ring -- independent of both hue and motion. Verified at 390px by forcing each status class and reading computed styles under both prefers-reduced-motion settings: connected filled (border-width 0), reconnecting transparent + 2px dashed, disconnected transparent + 2px solid; the pulse animation resolves to none under reduce while the dashed ring persists. Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT |
||
|
|
a6b48c75b9 |
fix(web): lane inline-create opens the split pane on desktop, stays put on mobile (IDEA-2298) (#1036)
The board lane `+` opens a Trello-style draft card (TASK-1676) whose Enter
handler hardcoded a full-page `goto(.../{item}?new=1)`. TASK-1676 predates the
split pane (PLAN-2105), so nothing revisited that destination, leaving the one
create gesture that already knows its title as the only card-open path that
bypasses the pane:
- Clicking an EXISTING card opens the split pane (`onItemOpen` → `?item=`);
creating one navigated the whole page away from the board.
- `?new=1` exists to drop you into the title editor of a fresh "Untitled" item
(`createNewItem`). On this path the title was just typed, so it re-opened the
title editor with that title select-alled.
- There was no viewport branching at all, so mobile — where the lane `+` is
fully present — got ejected off the board too, making a second add a Back
navigation.
`quickCreateInColumn`'s third param becomes `reveal` (caller INTENT) rather than
`navigate` (a destination): the local-index upsert is now unconditional so the
card always lands in its lane, and revealing means `openItemPane(item)` on
desktop and nothing on mobile. The page owns what revealing means, so BoardView
stays unaware the pane exists. The composer closes on submit on every viewport —
on desktop the pane takes focus, so keeping it open for rapid entry would fight
it. Revisit if feedback asks for mobile rapid-add.
The nav-guard's Save-all keeps passing `reveal: false`; saving drafts on the way
out must never open anything.
New e2e pins both destinations and, on both viewports, that the pathname never
changes and `?new=1` is never set. Two traps worth recording: the created card
renders off the SYNCHRONOUS local-index upsert, so it is not a sync point for
the navigation that follows — the first draft of both tests passed against the
reverted fix because the URL assertions raced an unresolved `goto`. Desktop now
waits on `?item=`; mobile can't poll an absence, so it proves the negative
positively by re-opening the lane composer (only possible if the board is still
mounted, and awaiting it gives a would-be navigation time to land). Verified by
mutating the fix back out: both fail for the right reasons, pass on the fix.
Gates: npm run check 0 errors, npm run test 490 passed, new spec 2/2, pane e2e
64/65 (the one failure is the pre-existing BUG-2334 SSE-toast flake, confirmed
by screenshot and passing in isolation). Codex review CLEAN (CONVE-735).
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
|
||
|
|
74813fcc72 |
revert(web): restore the pre-TASK-2328 item action bar, then make it fit (PLAN-2326 overturned) (#1035)
* Revert "feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033)" This reverts commit |
||
|
|
10a5ae2271 |
feat(web): dissolve the item action bar into a new .tab-strip wrapper (TASK-2328) (#1033)
Task 2 of PLAN-2326 (DR-4, DR-9) — the core of IDEA-2299. The `.meta-actions`
band is gone; its five controls are right-aligned into the tab row.
`.tab-strip` (flex, align-items:center) wraps the UNCHANGED `.pane-tabs`
tablist plus a new `.strip-actions` sibling holding the star, QuickActionsMenu
(its `{#key itemSlug}` wrapper intact), both jump badges, and the `.menu-anchor`
wrapper — moved whole, since it is the `position: relative` containing block the
anchored Menu positions against.
`.strip-actions` is a SIBLING of `.pane-tabs`, never a child: `role="tablist"`
is on `.pane-tabs` itself, so nesting the actions inside would put non-tab
children in a tablist and in range of the arrow-key handler's
`querySelectorAll('[role="tab"]')` walk (DR-4).
DR-9 width allocation: the actions never shrink (`flex: 0 0 auto`); the tab list
scrolls (`min-width: 0; overflow-x: auto`) rather than wrapping or crushing them.
The scroll rule is on `.pane-tabs` ONLY — an `overflow` value on the shared
`.tab-strip` ancestor would clip both anchored popovers. For the same reason the
wrapper carries no `contain` / `clip-path` / `transform` / `filter` /
`will-change`. `container-type: inline-size` is safe (layout/style/inline-size
containment, no paint containment) and is what TASK-2329's tier rule queries;
verified in Chromium that neither the anchored panels nor the mobile
BottomSheet's `position: fixed` overlay are affected.
Both badges split their single text node into `.badge-icon` + `.badge-count`
(DR-9) so TASK-2329 can hide the icon and keep the count. `title` / `aria-label`
and the literal space between the spans are preserved, so the computed
accessible names are byte-identical.
Also here:
- `.pane-tabs` gains `padding-bottom: 1px; margin-bottom: -1px`. `overflow-x:
auto` computes `overflow-y` to `auto`, which would otherwise clip
`.pane-tab`'s `margin-bottom: -1px` and leave the active tab a 1px accent on
1px of divider instead of a solid 2px underline (measured, then re-measured
after the fix: pixel-identical to before).
- The divider moves from `.pane-tabs` to `.tab-strip` so it spans the full strip
rather than stopping where the tabs end.
- `.action-btn`'s `min-width: 70px` is overridden under `.strip-actions` only —
the base rule stays for the graph-drawer controls.
- Explicit print hide for `.tab-strip` / `.strip-actions`; the old rule targeted
`.pane-tabs` and `.meta-actions` by name, and the new wrapper inherits neither.
Header stack: 222.8px -> 180.8px on the full page at 1440px (-42px), measured on
the same item and viewport across both builds.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
|
||
|
|
53dc0b7db8 |
fix(web): delete confirmation becomes an in-menu sub-view (TASK-2327) (#1032)
* fix(web): delete confirmation becomes an in-menu sub-view (TASK-2327)
Moves the inline `.delete-confirm` band out of `.meta-actions` and into
the pane `⋯` overflow as a third drill-down view alongside `move`
(PLAN-2326 DR-6). The band was a ~180px text-plus-two-buttons control
that could not survive the 360px pane the strip refactor (TASK-2328)
targets; as a menu sub-view it is width-independent by construction and
`sheetOnMobile` gives mobile a bottom sheet for free.
Ships first so main never carries a broken intermediate: the strip
refactor deletes `.meta-actions`, and until the confirmation moves, a
`Delete…` click would arm state with no confirmation UI rendered.
- `paneMenuView` widened to `'root' | 'move' | 'delete'`; the `{:else}`
branch that rendered the move-target list for EVERY non-root view is
split into explicit `move` / `delete` branches.
- `Delete…` drills down instead of closing the menu; `confirmDelete`
state is gone. Cancel returns to root, the view resets on close (the
existing `onclose`), and the item-switch / peek-freeze resets already
covered `paneMenuView`, so the armed-confirmation-survives-a-switch
hazard is unchanged. `handleDelete`'s failure path disarms, dismisses
the menu and returns focus to the trigger.
- Cancel is listed FIRST so the focus handoff lands on the
non-destructive row — Enter on arrival can never delete. The prompt is
a presentational div, so Menu's `[role^="menuitem"]` arrow-key walk
sees exactly the two actionable rows; MenuItem gains an optional
`describedBy` so the destructive row carries the prompt as its
aria-describedby (it would otherwise never be announced — Codex P2).
Also fixes the focus-handoff defect that the `move` sub-view already had
(DR-8, folded in per the fold-in-by-default rule): the focus $effect only
ran when `open` changed, so an in-place view swap stranded keyboard focus
on the unmounted MenuItem. `Menu` gains an optional `focusKey` prop that
the effect reads purely for dependency tracking, and forwards it to
`BottomSheet`, which owns focus in `sheetOnMobile` mode and had the same
gap (Codex P1). Both effects still only perform DOM focus/placement, so
neither can self-trigger (CONVE-1688). `ItemDetail` passes
`focusKey={paneMenuView}`, fixing move and delete together on both
surfaces.
Gates: `npm run check` 0 errors; `make check` exit 0; full Playwright
e2e suite green at CI worker count (77 passed). Verified by hand against
`make install` (40 scripted browser checks): in-place swap, cancel,
Escape-closes-and-returns-focus, reset-on-close, arrow-key walk inside
the sub-view, keyboard-only path, focus handoff on BOTH move and delete,
aria-describedby wiring, and an end-to-end delete (`deleted_at` set) —
across full-page, docked pane, mobile bottom sheet, and dark theme.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
* test(web): FreezeProbe mirrors the ⋯-menu route to delete/move (TASK-2327)
`FreezeProbe.svelte` is a hand-written mirror of ItemDetail's freeze /
permission gate expressions (BUG-2263). Its `delete-btn` and `move-btn`
rendered bar buttons, which no longer exist: #1029 moved Move into the ⋯
overflow and TASK-2327 moved Delete's confirmation there as a drill-down.
The probe stayed green while mirroring markup that was gone — `move-btn`
had been stale that way since #1029.
The row gate (`{#if canEdit}`) was in fact still correct; what was
missing was the REACHABILITY half. Both surfaces are now reached through
one trigger, so the probe mirrors it: `pane-more-btn`, with no canEdit
and no peeking gate (it renders on the peeking side and a click activates
that side first) and `disabled={moving}`. Without it, gating the trigger
on `!peeking` would take delete AND move off the passive side with every
existing assertion still passing.
Delete's confirm row gets its own model and test, because its gate is
genuinely different in two ways:
- It is NOT canEdit-gated. It renders whenever the 'delete' sub-view is
active and refuses via `disabled={deleting || !canEdit}`, so a
mid-confirm permission loss leaves it present but inert. (A first draft
wrapped it in `{#if canEdit}` — caught by Codex, since that would have
claimed the row vanishes when the real one does not.)
- It IS the one delete-related surface the freeze touches, and in the
opposite direction to everything else in the file: peek-begin
force-disarms it (ItemDetail's peek handler resets paneMenuOpen /
paneMenuView), so an armed confirmation can never survive into a peek.
The affordance itself stays live on the peeking side as before.
Mutation-tested — all four bite, each failing exactly one test:
peek-no-longer-disarms, confirm-drops-the-permission-guard,
canEdit-gate-the-confirm (the Codex finding), trigger-drops-its-in-flight
guard.
`make check` exit 0 (490 vitest tests, was 488); `npm run check` 0 errors.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
* fix(web): drop the probe's invented peek gate; mark the menu prompt presentational (TASK-2327)
Two review findings on PR #1032.
1. FreezeProbe gated the delete-confirm row on `deleteViewArmed && !peeking`.
That reintroduced the drift it was meant to fix, in a subtler form: the
real row renders on `paneMenuView === 'delete'` ALONE. Peek safety is an
EMERGENT effect of ItemDetail's peek-begin handler resetting paneMenuOpen /
paneMenuView — it is not a gate on the row. Encoding it as one is worse
than asserting nothing: delete the reset from ItemDetail and the probe
stays green off its own hard-coded `!peeking`, mirroring nothing. The
earlier mutation testing didn't catch this because mutating the PROBE only
proves the test is sensitive to the probe.
The gate is dropped and the render condition mirrored exactly. The
peek-disarm property is now explicitly NOT claimed, with the reasoning in
the file: a static prop-driven mirror can't express a transition, and e2e
can't discriminate it either — every click that causes a peek is also an
outside-click that closes the menu on its own, so a passing assertion would
prove nothing. Filed TASK-2337 for real coverage of that reset (it guards
five other surfaces too — editingTitle / shareDialogOpen /
editCollectionOpen / showAddLink — and nothing asserts any of them today).
Re-ran mutation testing on what remains; all four still bite, one test
each: confirm-drops-the-permission-guard, canEdit-gate-the-confirm,
trigger-drops-its-in-flight-guard, move-row-drops-its-in-flight-guard.
2. The prompt div inside `role="menu"` was undeclared. It now carries
`role="presentation"`. Verified against the rendered a11y tree rather than
assumed: the destructive row reports name "Delete item" / description
"Delete this item?", Cancel reports no description, and the menu's direct
children are [presentation, menuitem, separator, menuitem]. A second Codex
note corrected two overstatements in the comment — role=presentation is not
what excludes the prompt from the `[role^="menuitem"]` walk (a bare div was
already excluded), and a menu owns separator/group children too, not only
menuitems.
Gates: `npm run check` 0 errors; `make check` exit 0; delete flow re-verified
end-to-end (29 desktop + 11 pane/mobile + 7 a11y checks) against `make install`.
Claude-Session: https://claude.ai/code/session_01E2fRi12n8rARczvdEa2LYT
|
||
|
|
c676e08030 |
fix(web): Phase 5 sweep stragglers — home priority chips, count pill, activity from-value legibility (TASK-2295) (#1031)
The 31-shot both-theme sweep found three real stragglers (25 shots fully clean; console-billing + connected-apps are cloud-gated routes that can't render on a self-host box — noted, not bugs): - Workspace-home Active Work cards: priority was bare colored text — now the tinted chip treatment via --chip-c/--chip-alpha/--chip-text-mix. - Workspace-home header count: plain gray text → the count-pill treatment (matches PageHeader). - Activity page change pills: the 'from' value was near-invisible in dark — bumped to --text-secondary. |
||
|
|
8bc3c5f4c9 | fix(e2e): graph tests open the drawer via the pane ⋯ overflow (missed in #1029 — only capstone/host were re-run locally) (#1030) | ||
|
|
26c3f02136 |
feat(web): pane action bar consolidates into the ⋯ overflow (TASK-2294 PR B) (#1029)
PLAN-2290 Phase 4, PR B. The pane's action bar becomes the mock's trio — star, quick actions, ⋯ — with the count-carrying jump badges (🌳 done/total, 📎 N) retained as tab shortcuts: - Dependency graph / Move to collection… / Share… / Delete… move into a pane ⋯ Menu (primitive; BottomSheet on mobile; Move is a drill-down view inside the same panel, LaneActionsMenu precedent — replaces the old standalone move dropdown/sheet + showMoveMenu state). - The redundant Timeline text button is removed (the Activity tab IS the timeline entry point). - The Delete… row opens the existing inline confirm strip in the bar; handleMove/reset paths repointed to the new menu state. - Capstone e2e updated: pre-peek opens the ⋯ and asserts the rows; while peeking asserts the trigger stays enabled (the BUG-2263 liveness guarantee) instead of opening — opening would activate the side. Gates: svelte-check 0 errors, 488 unit tests, capstone+host e2e 16/16, ⋯ menu runtime-verified (screenshot). |
||
|
|
059cbcdcf3 |
fix(web): pane tabs activate on pointerdown (focus-follows click-swallow, CI-caught) (#1028)
* fix(web): pane tabs activate on pointerdown — the focus-follows cascade could swallow the click on a peeking master (CI-caught) The E2E (Playwright) job caught what fast local runs missed: clicking a peeking master's tab fires pointerdown (focus-follows flips activePane → peeking-state re-render cascade) and on slow runners the subsequent click lands after the churn and is swallowed — activeTab never set, the Details panel never shows, fill times out. Same same-click detach class as BUG-2281. Activating on pointerdown (click retained for keyboard) sets the tab in the same tick as the detector, before any re-render can intervene. Verified: the two CI-failing specs at --repeat-each=3 locally, 45/45. * fix(web): pointerdown tab activation is mouse-only (touch scroll-start must not switch panels — Codex) |
||
|
|
d04b714ccb |
feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294) (#1027)
* feat(web): item pane tabs — Details/Relationships/Activity/Versions, editor never unmounts (TASK-2294)
PLAN-2290 Phase 4, PR A. The mock's tabbed pane, built on the hard rule:
panels are CSS-hidden (.tab-hidden, display:none), NEVER {#if}-unmounted —
the collab editor, ChildItems/ItemTimeline SSE subscriptions, and
BacklinksPanel's count callback all carry mount side effects that must
survive tab switches.
- ItemDetail: pane-tabs tablist after the action bar; Details wraps Code
Context + .item-body (fields+editor, layout-{layout} preserved);
Relationships wraps relationships/add/children/backlinks (inside the
existing {#key itemSlug} block); ONE ItemTimeline instance serves both
Activity and Versions via the new visibleKinds render-filter. Tabs reset
to Details on item switch (guarded plain-let effect, no read-write loop).
Jump buttons switch-tab-then-scroll. Print shows all panels, no tab bar.
Tab clicks stay interactive while peeking and activate the side per the
focus-follows-editing model (deliberately NOT an exempt surface).
- ItemTimeline: visibleKinds?: ('comment'|'activity'|'version')[] —
filter-only over the one merged feed (no refetch on switch); composer
renders only when comments are visible.
- E2E: five specs updated — tab-click preludes where interactions target
tabbed sections; four frozen-master tests reworked to assert per-tab
visuals BEFORE the peek and DOM-based freeze proxies during it (the
per-surface freeze audit lives in masterFreeze/mutationGate unit suites).
Gates: svelte-check 0 errors, 488 unit tests, the five affected e2e specs
27/27 locally; runtime-verified collab badge synced across a full tab
round-trip, version filter (4 real cards), composer placement, editor DOM
alive throughout.
* fix(web): pane-tabs review fixes — title-Enter surfaces Details before editor focus; ARIA ids/roving-tabindex/arrow nav; block-drag hover integration restored via re-peek
Codex findings on #1027: (1) Enter-after-title-edit now sets
activeTab='details' + tick before focusing the editor (was focusing a
display:none node from other tabs); (2) tablist gains arrow-key roving
focus, per-instance aria-controls/id pairing ($props.id() — two ItemDetail
instances mount on the full-page host), tabindex discipline; (3) host
test 3 regains the end-to-end hover assertion: re-surface master Details
(activates), re-peek via the pane, hover the frozen editor, assert the
handle stays display:none — the reactive-editable choke verified in
integration again, not just by contenteditable proxy.
* fix(web): pane tabs use automatic activation on arrow nav (Codex — roving tabindex must follow focus; activation is free on display-toggled panels)
|
||
|
|
a94f2d37d4 |
fix(deps): bump otel to v1.42.0 — clears GO-2026-5506 + GO-2026-5158, un-reds main CI (#1026)
* fix(deps): bump go.opentelemetry.io/otel family to v1.42.0 (GO-2026-5506, GO-2026-5158) CI's Go job has been red on main since GO-2026-5506 published (reachable baggage/propagation symbols in otel v1.40.0). v1.41.0 fixes it but carries GO-2026-5158 (fixed in v1.42.0), so bump straight to v1.42.0 (otel + metric + trace in lockstep; sdk untouched per go mod tidy). Verified: make vuln (binary-mode govulncheck) exit 0, go build ./..., full go test sweep green (24 ok). * fix(deps): bump otel/sdk to v1.42.0 in lockstep with the API (Codex — otel compat policy pairs SDK with API version) |
||
|
|
1747054b10 |
feat(web): toolbar consolidation — View menu w/ saved views, sort/filter icons, collection ⋯ menu (TASK-2293) (#1025)
PLAN-2290 Phase 3, PR B. The collection-page desktop toolbar collapses from nine controls to five, per the refresh mock: - View dropdown (Menu primitive, trigger shows current view): List/Board/ Table as checked rows + the saved-views set folded in (activate rows, hover-revealed delete, 📌 default marker, Make/Remove default, 'Save current view…'). The saved-views tab bar is retired — TASK-1366 pin/default semantics carry over unchanged. - Sort select becomes an icon + Menu (menuitemradio rows; BottomSheet on mobile); hidden in table view as before. - Filters becomes an icon button with the active-dot riding its corner; the FilterBar expansion behavior is unchanged. - Archived toggle, Edit collection, Share collection move into a ⋯ Menu (owner-gated rows; BottomSheet on mobile). QuickActions ⚡ and + New stay. - Mobile view chip + sheet unchanged. 29 dead CSS blocks deleted (svelte-check-verified); saved-view delete button re-revealed on row hover (was tab-hover). Zero e2e coupling: suites pin views via ?view= URLs, none target toolbar selectors (verified). Gates: svelte-check 0 errors, 488 tests; both menus runtime-verified via Playwright interaction. |
||
|
|
e9e114e96c |
feat(web): TableView + share parity; fix subgrid collapse + hyphenated lane accents (TASK-2293/2208/2213) (#1024)
* feat(web): TableView + public-share parity; fix subgrid collapse and hyphenated lane accents (TASK-2293, TASK-2208, TASK-2213) PLAN-2290 Phase 3, PR A2. Parity: TableView status cells become Chip primitives (click-cycle + per-row pulse preserved; read-only tables get static chips), select-value cells colored via fieldColors, focused row = violet tint + accent bar (.table-row/.focused class names kept for e2e); Public* fork (card/list/table/expansion) gets the card-token skin and chip-style pills through the terminal-aware fieldValueColor, multi-select values render as purple tag pills. TASK-2208 (audit): content-visibility:auto implies layout containment, which disables subgrid per spec — every table row collapsed to a single stacked column in Chromium (internal AND public share). Fixed by making the column template fully extrinsic (minmax+fr, no auto tracks) so rows align identically via grid-template-columns: inherit. Runtime-verified: data rows 72px wrapped (was 243-309px stacks). TASK-2213 (audit): columnAccentClassFor now derives lane accents from the canonical STATUS_COLORS map (normalizes hyphens — the default template ships 'in-progress'), and negative-terminal lanes (cancelled/rejected/ wontfix) no longer read done-green. Gates: svelte-check 0 errors, 488 tests; table runtime-verified both the row geometry and the chip rendering. * fix(web): fence TableView pulse timer with a sequence guard (Codex — same-row double-click cleared the second pulse early) |
||
|
|
f994509289 |
feat(web): card anatomy — Chip status/priority, card tokens, violet ring, lane accents (TASK-2293) (#1023)
* feat(web): card anatomy per the refresh mock — Chip status/priority, card tokens, violet selection ring, lane accents (TASK-2293) PLAN-2290 Phase 3, PR A. ItemCard (shared by Board/List/starred/tags/roles): - Skin: --card-bg/--card-border/--radius-lg/--shadow-card; hover = border-strong (no transform — svelte-dnd-action owns card transforms); .focused becomes the mock's violet ring + glow (e2e asserts the CLASS, which is unchanged). - Anatomy: ref stays top-left; star moves to the right cluster before the kebab (ONE auto margin on the star — competing autos split the gap). - Status/priority render as Chip primitives (tinted pills; status keeps click-cycle + pulse via Chip props; labels Title Case, no more uppercase). - Tags become purple-tinted pills; leading separator before parent chip dropped (chips separate visually). - Dead CSS removed (meta-status family, status-pulse keyframes). Lane accents: columnAccentClassFor (shareView — shared with the public fork by construction) gains col-open for open/new/todo/planned; BoardView + PublicBoardView underline it --status-blue. Default underline unchanged for custom vocabularies. Gates: svelte-check 0 errors, 488 tests; board+pane screenshots verified in both themes. * fix(web): consolidate BoardView lane accents onto shared mapper + AA chip text in light theme Codex findings on #1023: (1) BoardView had its OWN columnCssClass — a fifth parallel status-color-ish map, so only public boards got col-open; it now delegates to shareView.columnAccentClassFor (in-app and public boards can't drift, and custom terminal lanes now read as done in-app too). (2) New --chip-text-mix token (100% dark / 72% light) darkens chip text on light surfaces — all chip colors verified >=5.8:1 on white (computed). * refactor(web): columnAccentClassFor moves to $lib/utils/fieldColors (Codex — dependency direction); shareView re-exports |
||
|
|
01a94d93a8 |
feat(web): Menu/MenuItem primitive — 3 menus migrated, escape-stack + portal + pointerdown dismissal (TASK-2292) (#1022)
* feat(web): Menu/MenuItem primitive — escape-stack ESC, portal mode, pointerdown outside-click (TASK-2292) PLAN-2290 Phase 2, PR 4 (final primitive). New shared machinery: - lib/components/common/Menu.svelte — anchored + portal modes (portal = fixed coords + flip/clamp, escapes card content-visibility containment), instance-scoped POINTERDOWN outside-click (structurally removes the BUG-2281 stopPropagation-on-rows detach workaround), ESC via the shared escapeStack at new priority menu=40 (one ESC closes menu before pane/drawer), roving keyboard nav, focus-in/focus-return, BottomSheet swap at 768px, --bg-raised panel skin. - lib/components/common/MenuItem.svelte — icon/hint/danger/menuitemradio rows. - lib/utils/clickOutside.ts + lib/utils/portalAction.ts — extracted from the hand-copied per-menu versions. - app.css: --bg-raised token (dark = tertiary; light = white). Migrated: ItemActionsMenu (portal mode, entire hand-rolled machinery deleted), QuickActionsMenu (anchored + sheetOnMobile, EmojiPicker exemption via exempt(), BUG-2281 workarounds removed), TopBar user menu (desktop + mobile branches deduped into one snippet; gains aria-haspopup/expanded + keyboard nav it never had). E2E locators updated to accessible-name form. Documented leave-alones: TopBar workspace-overflow menu (it IS a dndzone — conditional mount / focus-steal / pointerdown-close each break drag-reorder; in-file comments), LaneActionsMenu drill-down + WorkspaceSwitcher (Phase 3 / later). Gates: svelte-check 0 errors, 488 tests, make check green; runtime-verified via Playwright: user menu roving nav (ArrowDown x2 -> Admin), ESC closes via stack, kebab portal placement + edge-aware rows. * fix(web): Menu review fixes — form focus hand-off, drag suppression, scroll-close without refocus, resize close Codex findings on #1022: (1) QuickActions create-form now receives focus when it swaps in (the focused MenuItem unmounts on the flip); (2) clickOutside gains suppress() and TopBar's user menu passes isDragging||dragArmed so pill drags can't slam it shut (parity with the old drag guard); (3) portal scroll/resize dismissal calls onclose() directly — no trigger refocus fighting the user's scroll (parity with the old returnFocus=false); (4) resize now also closes portal menus (stale fixed coords). |
||
|
|
d087c7d822 |
feat(web): PageHeader primitive + generic EmptyState — 15 pages adopted (TASK-2292) (#1021)
* feat(web): PageHeader primitive + generic EmptyState; adopt across 15 pages (TASK-2292)
PLAN-2290 Phase 2, PR 3. PageHeader (title/icon/count-pill/description/actions
snippet) replaces 9 per-page header scaffolds; EmptyState gains a generic mode
(icon/title/message/actions) alongside its legacy collection mode, adopted at
19 rogue .empty-state sites. Net -430 lines; dead scoped CSS deleted; two
pre-existing dead selectors and an unkeyed {#each} fixed en route.
Documented leave-alones: breadcrumb header on tags/[tag] (interactive
view-toggle), console section-level h2s (PageHeader is h1 — semantics),
connected-apps empty (inline <a> in copy; message prop is string-only).
Gates: svelte-check 0 errors (warnings 7->6), 488 web tests, make check green;
conventions/starred screenshots verified.
* fix(web): PageHeader rows wrap on narrow screens (Codex finding — restores the responsive behavior the deleted per-page mobile rules provided)
|
||
|
|
6422324edd |
feat(web): Button primitive + dark text-on-fill AA — 95 sites migrated (TASK-2292) (#1020)
* feat(web): Button primitive + dark text-on-fill AA fix; migrate 95 button sites (TASK-2292) PLAN-2290 Phase 2, PR 2. lib/components/common/Button.svelte — variants primary (filled --accent-primary-strong #7c4ff0, the violet band where white text passes AA 4.96:1 while staying >=3:1 vs surface — pays off the PR #1018 deferral) / secondary / ghost / danger (red tint, AA both themes); size sm/md; full attr passthrough (type=submit preserved at form sites). 95 usages across 14 files migrated (settings, conventions, playbooks x2, workspace home, console suite, modals, comment composer, EmptyState); dead scoped .btn* CSS deleted (net -291 lines). Deliberate leave-alones per file: ItemDetail action-bar strip (Phase 4 owns the pane), anchors styled as buttons, segmented controls, icon-only buttons, dashed low-emphasis affordances. Gates: svelte-check 0 errors (dead-selector warnings down 8->7), 488 web tests, make check green; screenshots reviewed both themes. * fix(web): Button class-prop merge + danger-solid variant for final confirms Codex findings on #1020: (1) caller-supplied class no longer clobbers the primitive's classes — class is destructured and merged, rest spread moved first; (2) new danger-solid variant (filled --accent-red-strong #dc2626, white text 4.83:1 AA both themes) restores destructive emphasis on the two final-confirm flows that had gone pale (OpenChildrenDialog override, conventions delete Confirm); entry-level destructive buttons keep the tint. |
||
|
|
a335033415 |
feat(web): Chip primitive + canonical fieldColors util — 48 badge sites migrated (TASK-2292) (#1019)
* feat(web): Chip primitive + canonical fieldColors util; migrate 48 badge sites (TASK-2292) PLAN-2290 Phase 2, PR 1. Extracts the first shared primitives: - lib/utils/fieldColors.ts — ONE statusColor/priorityColor (+ hasCanonicalStatus, formatFieldLabel), replacing four drifted implementations (ItemCard, fields/FieldEditor, CommandPalette, workspace home); shareView.ts re-exports it so public shares stay in lockstep. Deliberate unifications: open/new/todo/ planned -> --status-blue (was text-secondary on cards); active -> green (was cyan in palette/home); draft -> muted (was blue); rejected/cancelled/wontfix -> gray; priority medium -> text-secondary. - lib/components/common/Chip.svelte — tinted-pill primitive per the refresh mock (color-mix tint via new --chip-alpha token, colored text, dot/size/ onclick/pulse props); svelte-autofixer clean. - 48 badge usages across 15 files migrated to Chip; scoped .badge CSS deleted (net -355 lines). Deliberate leave-alones: GraphToolbar count bubble + filter toggles, stat tiles, timeline rail markers, avatars. Gates: svelte-check 0 errors, 488 web tests, make check green; board/settings screenshots verified in both themes. * fix(web): Chip button variant always preventDefaults (never navigates a parent <a>) Codex finding on #1019: an onclick Chip inside a link card would activate the link after the callback. preventDefault always (a chip is never a link); propagation intentionally continues so click-outside closers work — callers in interactive cards stopPropagation per the house pattern. |
||
|
|
841a2cb4ea |
feat(web): violet retheme — accent-primary alias, neutral scale, card tokens, radius, AA text (TASK-2291) (#1018)
* feat(web): violet retheme — accent-primary, neutral scale, card tokens, radius, AA text (TASK-2291) PLAN-2290 Phase 1, PR B. Values-only retheme in app.css + theme-color meta: - --accent-primary #9268f8 dark / #7c3aed light; --accent-blue aliased to it (~95% of its 462 sites are brand usage; categorical sites moved to --status-blue in PR A and stay blue). Dark value chosen by contrast math: AA as link text (4.85:1) while improving white-on-fill from 2.75 to 3.78 (>=3:1 UI threshold; full text-on-fill AA lands with the Phase 2 Button primitive via --text-on-accent). - Violet-biased neutral scale both themes; light mode inverts to off-white canvas (#f5f5f9) with white surfaces per the mock. - Muted/secondary text re-picked: >=5:1 on every bg token in both themes (closes TASK-2262 C9 app-wide). - --border-strong/--card-bg/--card-border/--shadow-card defined both themes (consumed from Phase 3). - Radius scale 6/4/8 -> 8/5/12; light-mode danger tuned to #dc2626 (4.83:1). - theme-color meta #4a9eff -> #8b5cf6. Verified: make check green; screenshots on 4 surfaces x 2 themes reviewed; contrast ratios computed for all text-token pairs. * fix(web): violet PWA branding (manifest/icon) + pin light accents in print block Codex review findings on #1018: manifest theme_color/background and icon.svg still carried the blue brand; print block now pins light-theme accents so dark-theme printing doesn't put the bright violet on white paper. Dark button text-on-fill AA is explicitly deferred to the Phase 2 Button primitive (tracked in TASK-2292). * fix(web): regenerate apple-touch-icon.png from the violet icon.svg (180x180) * fix(web): regenerate remaining brand rasters from violet icon.svg favicon-16/32, favicon.ico (single PNG-encoded 48px entry), icon-192 (was 0 bytes), icon-512, padicon.png (OG image, 701x701) all regenerated from the canonical icon.svg 'P' mark — the old rasters were a blue calendar design inconsistent with the linked SVG. site.webmanifest colors updated to the violet scheme. |
||
|
|
563371ee9b |
feat(web): define missing token families + zero-change drift sweep (TASK-2291) (#1017)
PLAN-2290 Phase 1, PR A. Defines --accent-red, --status-blue, --text-on-accent, --shadow-sm/md/lg, --modal-shadow, --scrim in app.css (values matching the long-standing inline fallbacks), then mechanically sweeps: - var(--accent-red, #hex) fallback forms collapsed (52+4+1 sites); 3 bare var(--accent-red) sites that previously resolved to NOTHING now render - phantom var(--color-danger, #dc2626) repointed to --accent-red - bare #ef4444/#dc2626 danger literals -> var(--accent-red) (57 files); #c0392b/#e53e3e/#dc2626 outliers unify to #ef4444 (deliberate) - shadow fallback forms collapsed to the now-defined tokens - 10 categorical literally-blue sites (status maps, burndown chart, info badge) repointed --accent-blue -> --status-blue so PR B's violet accent flip won't drag status colors Verified: svelte-check 0 errors, 488 web tests, make check green; Playwright before/after pixel diff on 4 surfaces x 2 themes — identical except the sidebar build-id string. |
||
|
|
222c596a96 |
test(e2e): add BLOG-2289 v0.11 pane screenshot capture block (#1016)
Reusable blog-screenshot capture for the Pad v0.11 detail-pane post (pad-web/static/blog/pad-v0-11-item-pane/01-item-pane.png). Follows the existing BLOG-1007 / BLOG-1704 pattern; gated on PAD_BLOG_SCREENSHOTS=1 so it never runs in normal CI. Opens the docked pane via ?item=<ref> on a seeded, content-bearing task, and logs the browser session first so the pane's collab editor hydrates (WS auth is cookie-based) instead of capturing a loading skeleton. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
faf9b3734a |
feat(web): default new collections to Board — schema-aware (IDEA-2274, IDEA-2287) (#1015)
* feat(web): default new collections to Board view (IDEA-2274) Board becomes the baseline default view for new collections; existing collections keep their stored default_view (no migration). - Frontend fallback (settingsDefaults, collection-page defaultMode, shareView coerce, initial viewMode) -> board - Create/Edit collection modals default -> board - Backend template seeds (defaults.go, templates*.go) list -> board for ideas/plans/docs/hiring/interviewing collections (tasks was already board) - CLI `pad collection create` and MCP mapCollectionCreate defaults -> board - Curated create-modal presets with deliberate list curation (Meeting Notes, Decisions, OKRs) intentionally left as list - Pin the three list-keyboard-nav pane E2E tests to ?view=list Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): board default reaches public share page + ItemDetail fallback (Codex round 1) Codex review found the public share route (s/[token]) derives its owner default view via a separate `?? 'list'` fallback that bypassed the coerceSettings change, so settings-less/legacy collections rendered List on public share pages. Align it (and the pre-init selectedBase) to board. Also align ItemDetail's inline CollectionSettings fallback (default_view is unused there, but keep it consistent with settingsDefaults). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(collections): group Contacts board by relationship, not status (Codex round 2) Contacts has no `status` field, so defaulting it to Board grouped by the default `status` rendered every card in a single Uncategorized lane. Set BoardGroupBy=relationship so the board shows real lanes. All other board-defaulted seed collections have a status field or an explicit board_group_by (verified: Companies/Conventions/Playbooks/Docs have status). Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra * fix(web): always serialize ?view= so a List URL survives a board default (Codex round 3) buildCollectionUrlParams treated List as the implicit URL view and omitted it. With Board now a possible collection default (IDEA-2274), a List selection on a board-default collection produced a URL that, when copied or opened without the sender's localStorage, resolved back to Board. Always serialize the view mode; add a covering unit test. Verified the pane E2E suite (URL-equality assertions) stays green. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912rav0.11.0 |
||
|
|
14f624dd42 |
feat(web): show abbreviated item age on board & list cards (IDEA-2286) (#1014)
Add an item's age (created_at) to the shared ItemCard, right-justified in
the .card-meta row so it sits opposite the status — visible at a glance on
both Board (compact) and List views. TableView renders its own rows and is
unaffected.
Reuses the shared relativeTime() the item-detail header already uses
("3h ago", "5d ago", then a short date) rather than a bespoke format.
A dedicated .meta-spacer (not margin-left:auto on both assignee and age)
keeps the right cluster deterministic — two competing auto margins would
split the free space and strand the assignee mid-row. Absolute timestamp
on hover via a title tooltip.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
1693a0d264 |
feat(web): show an Uncategorized lane on the Board for items with no group value (IDEA-2275) (#1013)
The kanban board only bucketed items into the group field's known select
options, silently dropping any item whose value was empty, missing, or a
stale/removed option — those items were invisible on the board and could
only be found in other views.
Add a pinned "Uncategorized" lane (leftmost) that collects every such item,
rendered only when uncategorized items exist. Extract the bucketing into a
pure, unit-tested helper (bucketByColumn) that routes empty/unknown-value
items into an UNCATEGORIZED ('') lane instead of dropping them.
- Lane is pinned leftmost and kept OUT of the persisted, drag-reorderable
column order (can't be reordered into the middle or written to saved order).
- Droppable like any other lane: dragging a card in sets the group field to
'' (server-safe clear, reversible); menu-driven horizontal moves work in/out
of the lane via the render-order adjacency.
- Header drops the drag handle and the "+" add affordance (creating an
explicitly-uncategorized item makes no sense) but keeps the bulk "⋯" menu
for triage; dashed muted accent distinguishes it from real status columns.
- Keyboard nav follows the render order so the lane is navigable.
Verified live: Ideas board grouped by impact shows Uncategorized(202) leftmost
with Low/Medium/High, no console errors.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
ccf7dafe9e |
fix(web): inert the collapsed sidebar so off-screen nav leaves the a11y tree (BUG-2282) (#1011)
The mobile sidebar drawer collapses via translateX + pointer-events:none but stayed in the accessibility tree and tab order, so a screen-reader virtual cursor and keyboard Tab still reached its off-screen nav links. Bind `inert` to the same !sidebarOpen condition that drives the collapse class + the existing pointer-events:none rule, so a collapsed drawer leaves both the a11y tree and the focus order — covering the mobile drawer and the latent desktop width:0 collapse. The re-open control lives in TopBar (outside the aside) so nothing is trapped; swipe-to-open is a window handler, unaffected. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |
||
|
|
e2ec876be9 |
fix(web): make itemMatchesRef workspace-aware in ItemDetail (IDEA-2135) (#1008)
The no-{#key} switch-boundary predicate compared only ref/slug identity,
never workspace. On a reused embedded ItemDetail instance, navigating
ws1?item=TASK-1 -> ws2?item=TASK-1 (both workspaces owning TASK-1) kept
the predicate true across the switch, leaving collabKey pinned to ws1's
item.id and rawMode carried over until ws2's loadData resolved.
Stamp the wsSlug each item is loaded under (loadedItemWsSlug, lock-stepped
with item adoption inside the myItemGen===itemGen gate) and fold
loadedItemWsSlug === wsSlug into itemMatchesRef. scrollReady, collabKey,
resolvedIdentity, and the rawMode-reset gate all derive from it, so they
tighten together and stay consistent. Single-workspace usage is unchanged
(the arm is always true there).
TASK-2283.
Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
|
||
|
|
8c710e1db9 |
fix(web): stop paneOverlay ref-count effect self-looping on mobile (BUG-2284) (#1009)
PR #1007 (TASK-2131) added a PaneHost `$effect` that calls `paneOverlay.enter()`/`leave()` to inert the app-shell chrome behind the mobile detail-pane overlay. `enter()`'s `overlayCount += 1` READS `overlayCount` inside that tracked effect scope, so the effect took a reactive dependency on the very signal it writes: enter() dirtied the effect → it re-ran → enter()d again → `effect_update_depth_exceeded`. Svelte aborts the flush, stranding the rest of the subtree's reactivity — `paneMintForRoute` stopped recomputing, so the mobile pane (and its Back chevron) rendered EMPTY. The E2E `pane-controller` mobile-overlay tests caught it; #1007's own manual check verified the ARIA attributes but not that item content still rendered. Fix: `untrack` the count read in enter()/leave() so a write from an effect never establishes a self-dependency (the write still notifies the layout reader). The ref-count mutators are written from effects by design, so the untrack belongs in the store. Also fixes the second collision from the same #1007 change: the pane is now `role="dialog"` on mobile, so pane-controller.spec.ts:771's `[role="dialog"]` + text locator matched BOTH the pane and the BottomSheet (strict-mode violation). Target the sheet by accessible name ("Quick actions") instead — the pane's is "Item detail". The effect_update_depth_exceeded runaway only manifests under the real browser scheduler (not jsdom/vitest), so the E2E overlay tests own the loop regression; the unit tests lock the ref-count semantics. Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra |