mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
45d032cb094d9f8623fa6632e23bdf0dbf0a485f
427 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
45d032cb09 |
fix(web): graph node click/dbl-click + Open as real link (TASK-1788) (#725)
* fix(web): graph node click/dbl-click + Open as real link (TASK-1788) Three interaction fixes for the per-item graph (TASK-1787 follow-up): - Click-to-select and double-click-to-zoom didn't fire. Root cause: the viewport called setPointerCapture on pointerdown, which suppressed the SVG nodes' click/dblclick events. Now drag/capture engages only after the pointer moves past a 4px threshold, so a plain press still produces a node click; a gesture that became a pan suppresses the trailing click via a flag. - Open actions are now real <a href> links (controls "Open ↗" and the detail panel "Open item ↗"), so cmd/ctrl-click opens the item in a new tab; a plain click is a normal SvelteKit navigation that closes the drawer via the ?graph URL effect. Replaced the onOpenItem callback prop with an itemHref builder. Parent: PLAN-1780. * fix(web): robust pan/click suppression per Codex review (round 1) - Clear suppressClick when the drag gesture ends (deferred one tick so the trailing click is still suppressed) instead of waiting for the next node click. Fixes a pan ending on empty canvas leaving the flag set and swallowing the next intentional click. - Guard onPointerMove on e.buttons: if the primary button isn't held (press ended off-viewport before a drag engaged, so no pointerup was seen), abort instead of starting a ghost pan when the pointer returns. Parent: PLAN-1780. |
||
|
|
bd16b9b1b6 |
feat(web): graph view enhancements — larger drawer, node detail panel, toggleable legend, bigger cards (TASK-1787) (#724)
* feat(web): graph view enhancements — larger drawer, node detail panel, toggleable legend, bigger cards (TASK-1787) 1. Drawer opens wider — min(1500px, 94vw) — to take up the majority of the page. 2. Single-clicking a node selects it and opens a detail panel (ref, title, collection, status, terminal/child-count) with "Open item ↗" and "Focus here" (re-root) actions. Re-root/open are now explicit panel actions rather than bare-click behaviors, so a stray click can't navigate. 3. Legend entries are buttons that toggle a collection's visibility — hidden collections (and edges touching them) drop out via derived filters without recomputing the dagre layout, so positions stay stable. 4. Bigger node cards (212x60) showing up to two title lines; double-clicking a card zooms/centers on it. Parent: PLAN-1780 (follow-up). Drawer width lives in the item page; the rest in ItemGraph.svelte. Validated with svelte-check (0 errors) + build. * fix(web): overlay clicks don't start a pan + Fit frames visible nodes per Codex review (round 1) - onPointerDown ignores presses that land on an interactive overlay (legend, detail card, error retry) via a target.closest check, so clicking those controls no longer begins a viewport drag / pointer capture. Avoids adding pointerdown handlers to static divs (keeps svelte a11y clean); detail card role dialog→group (no tabindex requirement, supports aria-label). - fitView now computes bounds from the currently VISIBLE nodes (falling back to full bounds when none visible), so Fit frames what's on screen after hiding collections instead of centering on invisible nodes. Parent: PLAN-1780. * fix(web): Fit no-ops when all collections hidden per Codex review (round 2) currentBounds() now returns null when no nodes are visible (instead of falling back to the full graph bounds), so fitView() — including the post-reroot queueFit — no-ops rather than recentering on the hidden graph. Removed the now dead full-graph bounds tracking (contentBounds + runLayout bounds computation). Parent: PLAN-1780. |
||
|
|
a37fa16356 |
feat(web): deep-link the dependency-graph drawer via ?graph=1 (TASK-1786) (#723)
* feat(web): deep-link the dependency-graph drawer via ?graph=1 (TASK-1786)
- The item page auto-opens the graph drawer when loaded with ?graph=1, so
item pages / standup / chat can link straight into the view
(/{user}/{ws}/{collection}/{ref}?graph=1). One-shot capture mirrors the
existing ?new=1 pattern; always reassigned so a stale open state can't
linger onto a subsequent item.
- Opening/closing the drawer syncs the ?graph param (replaceState, no scroll)
so the open state is shareable and back-button-aware; setGraphParam no-ops
when already in sync to avoid redundant navigation.
- Centralized close via closeGraph() across the backdrop, Close button, and
Escape handlers.
Parent: PLAN-1780.
* fix(web): stale-guard deep-link auto-open + correct history claim per Codex review (round 1)
- Capture the ?graph intent at request scope (reqGraph) and only apply the
drawer open/close in loadData's finally when the load is still the current
route (req slugs match). Prevents a late-finishing earlier load from forcing
showGraph against a newer route.
- replaceState keeps ephemeral drawer toggles out of history by design; dropped
the inaccurate "back-button-aware" claim from the comment (shareable is the
real property).
Parent: PLAN-1780.
* fix(web): apply graph deep-link only on navigation, not data refreshes per Codex review (round 2)
loadData() runs for same-item SSE/sync refreshes as well as navigation. The
finally block forced showGraph from the URL on every run, so a refresh could
stomp a drawer toggle the user made mid-refresh. Thread an isNavigation flag
(true only from the route-param effect) so refreshes never touch showGraph;
the deep-link auto-open fires only on actual navigation.
Parent: PLAN-1780.
* refactor(web): make ?graph the single source of truth for the drawer per Codex review (round 3)
Same-slug ?graph navigations and browser back/forward update page.url without
retriggering loadData, so the prior loadData-coupled approach left showGraph
stale for search-param-only navigations. Drive showGraph from a dedicated
effect that watches the ?graph param instead:
- Handles initial load, navigation, back/forward, and param-only changes
uniformly; data refreshes never touch it (they don't change the URL), which
also resolves the earlier refresh-stomp finding.
- open/close just flip the param (setGraphParam); the effect reflects it. The
lazy component load is deferred to a microtask so reading ItemGraphComp
doesn't register as an effect dependency (no reactivity tangle).
- Added retryGraphLoad() for the import-failure Retry (param already set, so
setGraphParam would no-op). Removed the now-unused loadData coupling
(isNavigation flag, reqGraph capture, finally block).
Parent: PLAN-1780.
|
||
|
|
4456030938 |
feat(web): live SSE updates in the dependency-graph drawer (TASK-1785) (#722)
* feat(web): live SSE updates in the dependency-graph drawer (TASK-1785) Subscribe ItemGraph to the workspace SSE stream while open so the graph reflects changes live: - Correlate events to visible nodes by item UUID (added id to RenderNode). - item_updated: glow the node + debounced BACKGROUND refetch (no spinner, no view-refit) to pick up status/title/terminal changes. - item_created/archived/restored: debounced refetch (structural). - comment_created: glow only (ambient liveness). - onSyncRequired (bulk updates / replay-gap backfills): folded into the same debounced refetch. Refactor the load path into load(background): background mode keeps the last good graph visible and swallows errors so an ambient blip never replaces a working view with an error card. Transient node glow auto-clears after 2.5s; a small "updating…" indicator shows during a background reload. Subscription is set up in onMount and torn down on close (drawer-scoped). Parent: PLAN-1780. * fix(web): gate SSE refetch to in-view items + invalidate load on teardown per Codex review (round 1) - handleItemEvent now no-ops unless the event's item is in the current neighborhood, so unrelated workspace edits no longer trigger background refetches against api.graph.getFocused. A newly-linked neighbor still surfaces: linking touches the visible endpoint (item_updated, in-view) and that refetches. item_created is ignored (never already in view). - Bump loadToken in the onMount teardown so an in-flight load can't commit $state or queue fitView after the drawer closes / component unmounts. Parent: PLAN-1780. * fix(web): clear pending glow timers on teardown per Codex review (round 2) The per-node glow setTimeout could fire after unmount and write touchedRefs on a destroyed component. Track the handles and cancel them in teardown, matching the refetchTimer/loadToken teardown discipline. Parent: PLAN-1780. * fix(web): burst-safe node glow with per-ref timers per Codex review (round 3) touch() now resets a per-ref fade timer instead of stacking timeouts, so a burst of events for the same node keeps it lit and the fade starts GLOW_MS after the LAST event. Replaced the one-shot keyframe with a transition-based glow (filter transition on .node-bg) so rapid re-touches don't fight an animation that can't restart — the node stays lit while touched and fades out when the ref leaves touchedRefs. Kept onSyncRequired → debounced refetch (round 3 finding 2): it's the staleness-recovery net for bulk-update and replay-gap-reconnect events, which are rare and carry no item_id to filter on. The 400ms debounce collapses bursts to a single background reload, so it's not a per-edit refresh path. Parent: PLAN-1780. * fix(web): guard background refetch until first graph is ready per Codex review (round 4) scheduleRefetch() now no-ops unless loadState === 'ready'. Previously an onSyncRequired/SSE event during the initial foreground load could start a background load(true) that bumped loadToken (cancelling the foreground load) while skipping fit + error handling — leaving the view un-fitted, or stuck on the spinner forever if that background fetch failed. The in-flight initial load already fetches the latest data, so dropping events before the first ready render loses nothing. Parent: PLAN-1780. * fix(web): defer (don't drop) refetches during initial load per Codex review (round 5) The loadState !== 'ready' guard prevented the spinner/fit race but dropped the recovery signal for mutations arriving mid-initial-load: if a change lands after the initial request is issued, that response can be stale and no later event would correct it. Now such requests set a pendingRefetch flag that load() flushes once it commits a ready graph, so the neighborhood always reconciles. Parent: PLAN-1780. * fix(web): foreground load cancels pending background refetch (root-cause) per Codex review Close the load/refetch race class generally rather than per-path: - A foreground load (initial / reroot / depth / include-done) now clears any armed background refetch timer at start, so the delayed callback can't fire later, bump loadToken, and cancel the foreground load (which on a background failure left the view stuck on the spinner). - The background timer callback re-checks loadState === 'ready' at fire time and defers (pendingRefetch) instead of clobbering if state has moved on. Parent: PLAN-1780. * fix(web): refetch on all item events (not just in-view) per Codex review Reconcile the refetch-breadth tradeoff in favor of correctness. The server publishes structural link changes (new child, reparent) on the CHILD — which may be outside the current neighborhood — and the SSE payload carries no link info, so gating refetch on in-view items missed newly-attached/reparented neighbors. Refetch on any item_updated/created/archived/restored instead; glow stays in-view-only (off-view items have no node to light up). Cost is bounded by the 400ms debounce (one small getFocused per burst) and the drawer's transient lifetime. Parent: PLAN-1780. * fix(web): coalesce in-flight refetches + recover from error per Codex review (round 8) Two concurrency fixes for the SSE refetch path: - Coalesce: a loadInFlight guard prevents starting a new background load while one is awaiting. Previously a steady event stream (spaced > debounce but faster than getFocused+layout) kept bumping loadToken and invalidating in-flight responses, so live updates could starve forever. Now at most one load runs at a time; deferred events flush (exactly once) when it settles. - Error recovery: refetch decisions moved into runRefetch(), which retries foreground from the 'error' state on the next live event. Previously events during/after a failed load only set pendingRefetch (flushed solely on a successful ready), so the graph could stay stuck in error until a manual retry. pendingRefetch is now flushed from load()'s finally on every outcome. Parent: PLAN-1780. |
||
|
|
4bd048c8fc |
feat(web): item-header Graph button + dependency-graph drawer (TASK-1784) (#721)
Wire the 2D ItemGraph renderer into the item detail page:
- New "🕸 Graph" action button in the item header (shown when the item has
an issue ref) opens a right-docked drawer overlay (full-screen on narrow
viewports) hosting ItemGraph, focused on the current item.
- The renderer is dynamically imported on first open, so it (and its dagre
layout lib) stay out of the item-page bundle.
- Backdrop click, Close button, and Escape all dismiss; the ESC listener is
only attached while the drawer is open.
- Clicking a node's open action navigates to that item, building the
/{user}/{ws}/{collection}/{ref} URL — ItemGraph.onOpenItem now passes the
node's collection so the URL is correct without a lookup.
Parent: PLAN-1780.
|
||
|
|
771d102e89 |
feat(web): 2D directional dependency-graph renderer (TASK-1783) (#720)
* feat(web): 2D directional dependency-graph renderer (TASK-1783)
Add ItemGraph.svelte — a reusable, self-contained component that renders a
single item's dependency neighborhood as a 2D layered graph (parents above,
children below) via dagre layout + SVG.
- Lazy-imports @dagrejs/dagre so the layout lib stays out of the main
bundle (confirmed split into its own chunk).
- Fetches via api.graph.getFocused; re-roots on an internal currentFocus so
clicking a non-focus node navigates the chain, clicking the focus node (or
Open ↗) opens it. Depth (1–5) selector + include-done toggle; breadcrumb
back to the original root.
- Edge styling by type (hierarchy tethers, red directional blocks arrows,
faint wiki-link dashes, muted others); collection-colored nodes with
focus highlight + dimmed terminals; pan/zoom + fit-to-content; legend and
a truncation notice.
- Reactivity follows CONVE-1688 (loadToken guard, no read+write of one rune
in an effect) and CONVE-606 (data-load effect split from prop-sync).
Extract the collection palette into a shared $lib/graph/palette.ts and point
the 3D workspace graph at it too, so both views agree on collection colors.
Parent: PLAN-1780.
* fix(web): correct hierarchy layout direction + node border color-mix per Codex review (round 1)
- P1: 'parent'/'implements' edges are child→parent in the API, so feeding
them to dagre as-is put children above parents under rankdir TB. Reverse
those edges for layout ranking only; rendered edges keep true source→target
so 'blocks' arrowheads still point correctly.
- P2: unfocused node border used a JS string literal with an un-interpolated
{n.color}, yielding an invalid CSS color and a dropped stroke. Use a
template literal so the collection tint actually applies.
Parent: PLAN-1780.
|
||
|
|
f45a45a142 |
feat(web): graph.getFocused client + truncated field for focus mode (TASK-1782) (#719)
Surfaces the TASK-1781 backend focus mode to the web client:
- GraphResponse gains an optional `truncated?: boolean` (focus-mode-only;
absent for the whole-workspace view).
- New api.graph.getFocused(ws, focusRef, { depth?, includeTerminal? })
builds ?focus=REF&depth=N[&include_terminal=true]. The existing
graph.get is unchanged so the 3D view keeps working.
Parent: PLAN-1780.
|
||
|
|
dc8cb783d9 |
feat(web): surface Apple as a linked provider in console settings (TASK-1777) (#715)
Follow-up to TASK-1773: the backend now persists 'apple' in oauth_providers and accepts it for unlink, but the settings UI hardcoded [github, google] — a native-Apple user (PLAN-1772) had an invisible, unmanageable linked provider. Linked Accounts is now a data-driven list with a webLinkable flag. GitHub/Google have a /auth/<provider>/link redirect flow; Apple does NOT (Sign in with Apple is native-iOS-only — no web link route). So Apple appears with a Linked badge + Unlink once linked from the app, but is never shown a 'Link' button (an unlinked Apple row with no action would be noise — it's hidden until linked). Unlink works for all three (the server accepts apple, TASK-1773). Provider-name lookup replaces the github/google ternaries in the unlink + link-redirect-status messages. Login page + lastMethod intentionally unchanged: provider=apple can't reach the web /login?error= path (the native Apple endpoint returns JSON to the app, never a browser redirect), and the existing '... ? provider : null' collapse already renders a correct generic fallback. Adding web Apple retry CTAs would be wrong — they'd 404, since there is no web Apple flow. Revisit only if web Apple sign-in is added. |
||
|
|
00d4fe066b |
fix(web): quick-capture docks above bottom nav, mutually exclusive with other sheets (BUG-1765) (#713)
The center + set captureOpen without closing the other nav surfaces and wasn't a toggle, so tapping + with Search open stacked both sheets. QuickCaptureSheet now presents as a DockedSheet (anchored above the nav, like Workspace/You and the search palette) and BottomNav routes all four surfaces through a shared closeAllSurfaces() so opening any one closes the rest; a second tap on + collapses it and the slot lights while open. |
||
|
|
ba734d55e7 |
fix(editor): symmetric table cut/paste — structural row cut, TSV cell reconstruction (BUG-1247) (#712)
* fix(editor): table cut removes rows structurally; paste expands TSV into cells (BUG-1247)
Cut whole-row CellSelection now calls deleteRow (structural transform,
correct undo in one step) instead of deleteSelection (content-clear).
If all rows are selected the table is removed entirely via deleteTable.
Partial cell selections continue to clear content, preserving the
ProseMirror semantic for partial cuts.
Add a paste handler in tableCopyPlugin: when the anchor is inside a
table cell, text/plain contains tabs or newlines, and text/html carries
no <table> element, parse the TSV into a 2D grid and fill successive
cells from the anchor position, clamping at the table's right/bottom
edges (no table growth). Spreadsheet pastes with <table> HTML fall
through to ProseMirror's existing HTML handler unchanged. Single-value
paste (no tab/newline) falls through to tiptap-markdown's transform.
Known limitations documented in code: RFC-4180 quoted multi-line cells
are not parsed (naive \n split), and paste overflow is clamped/dropped.
* fix(editor): iterate paste cells in reverse to avoid stale doc positions (BUG-1247)
The previous forward-order loop computed all cellPos values from the
pre-transaction TableMap, but each tr.replaceWith shifts subsequent
doc positions. From the second fill onward, stale offsets indexed into
a doc that had already been mutated — wrong cells were written or (with
large size deltas) replaceWith would corrupt the document silently.
Test C in verify-paste.cjs demonstrated the corruption concretely:
'NEW-R1C0' was dropped and 'NEW-R1C1' landed inside an adjacent cell's
content, producing 'loNEWNEW-R1C1'.
Fix: collect cells to fill, reverse the list (bottom-right → top-left),
then re-read each cellNode from tr.doc at its (now-valid) cellPos before
computing contentStart/contentEnd. Reverse order means each replaceWith
only displaces positions that come later in the document — cells not yet
processed sit at lower positions and are unaffected.
Runtime-verified with a throwaway CJS node script against the repo's own
prosemirror-model + prosemirror-tables: 2×2 paste into variable-length
3×3 table passed; edge-clamped 3×3→(1,1) paste passed; forward-order
control test confirmed corruption.
* fix(editor): tighten TSV paste guard to tab-only; document header-row cut schema (BUG-1247)
TSV guard: require \t, not (\t OR \n). The previous guard admitted
newline-only text (code snippets, multi-line prose, addresses), causing
the grid-fill loop to fire and overwrite cells downward. Now only
clipboard text containing a tab character enters the TSV handler.
Accepted tradeoff documented in comment: our own single-column cut
produces tab-free output (one cell per line, no \t), so that cut result
won't round-trip via this handler — it lands as multi-line text in the
anchor cell. Spreadsheet single-column pastes are unaffected; they
carry text/html <table> and fall through at guard 2.
Header-row cut: deleteRow on row 0 is safe and intentional. The tiptap
Table extension schema is `table: { content: "tableRow+" }` and
`tableRow: { content: "(tableCell | tableHeader)*" }` — no mandatory
leading header row (verified at node_modules/@tiptap/extension-table/
src/table/table.ts:270 and src/row/table-row.ts:27). Removing row 0
yields a valid table; the current deleteRow path is correct. Comment
added recording the schema check so a future reviewer doesn't revisit.
Runtime-verified with verify-paste2.cjs (node script against repo's
own prosemirror-model + prosemirror-tables): newline-only paste falls
through (guard returns false, doc unchanged); tab-separated pastes
admitted correctly; old (\t OR \n) guard confirmed to have admitted
the code-snippet input.
* fix(editor): rectangular TSV for merged cells; paste dedup by physical pos (BUG-1247)
Serializer (copy/cut): replace forEachCell-based row accumulation with a
slot-by-slot walk of the selection rect via TableMap. For each (row,col)
slot, check whether it is the origin of its physical cell (cellRect.top
=== row && cellRect.left === col). Origin slots emit the cell's text;
covered slots (rowspan/colspan neighbours) emit an empty field. This
guarantees every TSV row has the same field count and columns align
correctly on paste. Previously, a colspan=2 cell in a 2-col selection
would emit one field for that row instead of two, misaligning all
subsequent columns.
Paste fill: add a Set<number> dedup keyed on physical cell offsets
(positionAt return values). Collect entries in forward (top-left →
bottom-right) visual order so the first encounter per physical position
is the origin slot's grid value — matching spreadsheet convention. Then
reverse for the transaction so each replaceWith only displaces positions
already processed. Without dedup, the merged cell was written once per
covered slot; with reverse order the last write (top-left) won but the
intermediate writes corrupted the document (verified: 'NEW00NEW01'
instead of 'NEW00' in the colspan=2 case).
Runtime-verified with verify-paste3.cjs against the repo's own
prosemirror-model + prosemirror-tables:
A) colspan=2 serialization → "A\t\nB\tC" (PASS)
B) rowspan=2 serialization → "A\tB\n\tC" (PASS)
C) colspan=2 paste dedup → origin gets top-left value, no double-write (PASS)
D) rowspan=2 paste dedup → origin gets top-left, covered slot skipped (PASS)
E) old code (no dedup) confirmed 'NEW00NEW01' corruption (PASS)
* fix(editor): use map.map[] instead of positionAt() to avoid covered-slot column shift (BUG-1247)
positionAt(row, col, table) SKIPS covered slots: for a visual slot
covered by a rowspan from above, it advances past the covered entry and
returns the next physical cell in that row. Example: 2-col table, (0,0)
has rowspan=2. positionAt(1,0) returns cell C (col 1) instead of cell A
(the owner). With the dedup Set in forward order, grid[1][0] (a col-0
value) is collected as the entry for C (col 1) — a column misalignment.
grid[1][1] is then dropped as a duplicate.
Fix: read the owning cell's table-relative offset via the raw map array
(map.map[row * map.width + col]) instead of positionAt(). The raw array
stores the owning cell's offset for EVERY visual slot, including covered
ones — identical to what the serializer's slot-by-slot walk uses. With
this change, covered slots resolve to their owning cell's offset; the
dedup Set drops the covered-slot grid value; the next non-covered slot
in the same row resolves to a distinct physical cell and receives the
correct column-aligned value from the grid.
Verified correct semantics with verify-paste4.cjs (16 assertions):
A) colspan=2 serialization rectangular — PASS
B) rowspan=2 serialization rectangular — PASS
C) colspan=2 paste dedup — PASS (regression)
D_new) rowspan=2 paste: A←R0C0, B←R0C1, C←R1C1 (R1C0 dropped) — PASS
D_bug) positionAt() confirmed: C wrongly gets R1C0 — CONFIRMED
F) colspan=2 covered-right: A←R0C0, R0C1 dropped, P←R0C2 — PASS
* fix(editor): origin-check guards covered-slot writes outside paste rect (BUG-1247)
map.map[] resolves covered slots to the owning cell's offset even when
that cell's origin lies outside the paste rectangle. Without an origin-
check, a covered slot whose owner lives above or left of the anchor would
silently overwrite a cell the user never selected.
Fix: after computing the slot's owning-cell offset via map.map[], call
map.findCell(cellOffset) and compare the owner's rect.top/rect.left to
the current targetRow/targetCol. If they differ, this is a covered slot
(or a partial-intersection slot) — skip it. This is structurally correct
for both cases: intra-rect covered slots (origin inside rect, duplicate
offset) and partial-intersection slots (origin outside rect, distinct
offset the dedup Set would never have caught).
The dedup Set is kept as a cheap invariant guard — an origin slot can
now only recur via a malformed cell with colspan > table width, which is
impossible in a valid ProseMirror document. The Set documents intent and
protects future edits.
Verified with verify-paste5.cjs (26 assertions — full regression A-F
plus new cases G and H):
G) rowspan origin ABOVE anchor: A untouched, C gets NEW01 — PASS
(buggy version confirmed: A clobbered with NEW00)
H) colspan origin LEFT of anchor: A untouched, P gets R0C1 — PASS
(buggy version confirmed: A clobbered with R0C0)
|
||
|
|
b4e9e200c9 |
fix(web): collapse WorkspaceSheet switcher list when the sheet closes (IDEA-1720) (#711)
The inline workspace-switcher list state lives in WorkspaceSheet, which stays mounted while DockedSheet's children unmount — so an expanded list survived close/reopen of the mobile Workspace tab. Replace the plain $state with a reassignable $derived keyed on `open`: the card toggle still works by reassignment, and the list snaps back to collapsed on any open-state change, including backdrop/swipe dismissal that no handler in this component observes. |
||
|
|
35cc26daaf |
fix(web): collection cards show real child-item progress; child-progress endpoint (BUG-1509) (#710)
* BUG-1509: show real child-item progress on non-plan collection cards
Backend: extract collectionChildrenProgress helper from handlePlansProgress
and expose it at GET /collections/{collSlug}/child-progress with identical
visibility/guest-grant filtering. handlePlansProgress refactored to delegate
to the shared helper (no duplication). Route registered in the existing
/{collSlug} subrouter alongside checkbox-progress.
Frontend: +page.svelte fetches child-progress + checkbox-progress in parallel
for non-plans collections; per-item merge prefers child-progress (label
"tasks") when total>0, falls back to checkbox counts (label "done"). ItemCard
extended to render progress.label when present. ChildItems.svelte render gate
fixed to include error state so a failed /children fetch surfaces instead of
silently vanishing.
Tests: TestCollectionChildProgress covers happy path (linked children counted
correctly), zero-children items (present with total=0), 404 for unknown
collection, and restricted-member visibility gate (empty response for hidden
collection, not a data leak).
* fix: include_archived on child-progress and progressLabel desync (codex r2)
P1: GetAllItemProgress now accepts includeArchived bool; the parent-row
filter (AND p.deleted_at IS NULL) is conditioned on it, mirroring
CollectionCheckboxProgress. handleCollectionChildrenProgress reads
?include_archived=true and threads it through. handlePlansProgress
hardcodes false — no contract change there. collectionChildProgress()
client method gains opts?: { includeArchived? } with qs() serialisation.
Both call sites in +page.svelte (loadCollection and refreshProgress) now
pass includeArchived to the child-progress fetch.
P2: refreshProgress plans branch now sets progressLabel = 'tasks' so a
sync-triggered refresh after a failed initial plans load renders with the
correct label. progressLabel = 'done' moved inside the non-plans try block
(symmetric with plans) so a thrown fetch leaves the label in whatever
state the previous collection set, not silently desync'd.
Tests: TestCollectionChildProgress extended — archives parentA, confirms
it drops from default response and reappears with include_archived=true.
* fix: thread includeArchived through childrenDoneFiltersForCollection (codex r3)
GetAllItemProgress conditionally drops the p.deleted_at IS NULL parent
filter when includeArchived=true, but the filter-discovery call at the
top of the function — childrenDoneFiltersForCollection — still had the
filter hardcoded. If a child collection's only parent links pointed to
archived parents, that collection was absent from the done-semantics map,
and those children fell back to default status terminals rather than the
collection's configured done field — producing wrong done counts.
Fix: childrenDoneFiltersForCollection gains an includeArchived bool param;
the JOIN on items p conditions p.deleted_at IS NULL on it, exactly mirroring
the main query. GetAllItemProgress passes includeArchived through. The only
other caller of this helper (GetItemProgress via childrenDoneFiltersForParent)
is unaffected — that path is a separate function and never surfaces archived
parents.
Test: TestCollectionChildProgress extended with a "Widgets" collection whose
done field is `state` (terminal: "shipped") — not the default `status` field.
An archived task parent links two widget children (one shipped, one open); no
live task parent links into widgets, so the filter-discovery bug would drop
the collection from the map and produce done=0. The test asserts done=1 and
was verified to fail on the pre-fix code.
|
||
|
|
eb5b2bf5de |
fix(web): comment wikilinks resolve refs + carry username prefix (BUG-1744) (#709)
renderMarkdown's plain [[body]] branch only matched exact item titles,
so [[REF]] links in comments rendered as broken (orange strikethrough)
while item content resolved them fine via wikiLinksToMarkdown's
ref-first lookup. Extract the shared resolveWikiBody() — ref-first,
then legacy title (full-body pipe form, case-insensitive key,
collection/Title) — and use it from both renderers so they can't
drift again. Also aligns renderMarkdown's [[...]] capture with the
escape-aware regex.
Verification surfaced a second latent bug: the item page never passed
username into ItemTimeline, so resolved comment links pointed at
/{ws}/... instead of /{username}/{ws}/... and landed on a dead route.
Plumb username through ItemTimeline → TimelineCommentCard.
Reported in GH #697.
|
||
|
|
0234239547 |
feat(web): graph node labels — always-on for hubs, distance-faded elsewhere (TASK-1743) (#708)
Camera-facing ref labels (three-spritetext, dynamic-imported alongside the renderer so it stays out of the entry bundle) make the graph readable without tap/hover. Anti-soup rules: hubs (child_count > 0) always labeled; in focus mode the selected node + neighborhood + blocker chain stay labeled while dimmed nodes hide theirs; everything else fades in between 160 and 120 camera units. The visibility walk is imperative (throttled orbit-controls 'change' listener + repaint lockstep + payload commits) and only toggles sprite.visible/opacity — nodeThreeObject is set once and never re-assigned, preserving the BUG-1742 no-flush discipline. Label offset clears the actual sphere radius (4·∛val + pad) so big hub labels don't render inside the node. Parent: IDEA-1740 (graph follow-ups). |
||
|
|
38f18ac534 |
fix(web): graph repaints in place instead of full-scene refresh (BUG-1742) (#707)
graph.refresh() sets three-forcegraph's _flushObjects flag, which destroys and recreates every Three.js object in the scene. Desktop GPUs hide the rebuild inside a frame; on mobile it reads as a full-screen flash on every select, deselect (background tap), SSE pulse, and 2s fade tick. Replace all refresh() call sites with repaint(): re-assigning fresh closures for the selection/pulse/chain-dependent accessors (nodeColor, linkColor, particle trio) takes the lib's in-place material/particle update path — objects survive, no flash. linkWidth is the one visual accessor whose prop-change DOES flush link objects (cylinder geometry is in the clear list), so width is now static per edge type and chain emphasis rides on full-alpha red + particles, as it already did visually. Also kill the gray mobile tap-highlight on the canvas. Reported by Dave on mobile right after PLAN-1730 shipped. |
||
|
|
30bdbeb967 |
feat(web): graph layout tuning — gravity wells + terminal recede (TASK-1738) (#706)
Per-link-type force tuning turns the undifferentiated force soup into structure: parent/implements links pull short and strong (children orbit their parent), blocks links keep medium tension, wiki-link / related stay long and weak so associative filaments don't collapse clusters into each other. Charge repulsion scales with subtree mass (clamped at 10 children) so big plans carve out space. Accessors are applied once at construction — d3-force-3d re-runs them on every graphData swap (links()/nodes() re-init), so payload/filter changes re-settle automatically with the lib's built-in re-heat. Terminal nodes (visible with show-completed on) recede: 0.4× size and collection color faded toward the backdrop — burned-down work reads as embers. Precedence: chain > pulse > dim > terminal; a terminal node on a blocker chain still burns full red. Parent: PLAN-1730. Closes the plan's task list. |
||
|
|
2904802486 |
feat(web): blocker-chain tracing as lit path + edge styling by type (TASK-1737) (#705)
Selecting a node now answers "why can't this start?" visually: a cycle-safe BFS walks the transitive blocker chain upstream over 'blocks' edges and burns it bright red — chain nodes mix toward the blocks red and are never dimmed, chain edges go full-alpha wide with directional particles flowing blocker→blocked into the selected node. Precedence: chain > pulse > dim for nodes, chain > adjacency > dim for edges. The chain recomputes when a surviving selection's payload or filters change, and clears on deselect. DetailCard grows a red-accented "Blocked by" list (clickable rows fly to the blocker and re-trace its chain; capped at 6 with +N more, chain depth noted when deeper) and a "Blocks N items" stat. supersedes / split-from edges drop to 0.6 alpha to round out per-type styling. Parent: PLAN-1730. |
||
|
|
1bd3e52230 |
feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736) (#704)
* feat(web): graph SSE live layer — glow/pulse on touched nodes (TASK-1736) The graph now feels alive while agents work: item events from the workspace SSE stream flash the touched node toward white and fade it back over 45s (a lazy 2s prune interval animates the decay and stops itself when idle). Structural events (created/archived/restored) and item_updated fold into one trailing-debounced refetch (1.5s) through the existing loadGraph stale-token path; comment_created is glow-only. New items arrive glowing via a pending-uuid stash resolved when the refetch lands. Pulse composes before focus-mode dimming so touched nodes still flicker subtly in the dimmed crowd. Selection clears when the selected item leaves the payload (archived under focus mode). Events correlate via a uuid→ref bridge rebuilt per payload — the graph endpoint now emits each node's item UUID alongside the ref. Parent: PLAN-1730. * fix(web): refetch graph on sync_required per Codex review (round 1) items_bulk_updated and replay-buffer gaps route through onSyncRequired, not onItemEvent — the graph stayed stale after bulk archive/move/assign until the next single-item event. Fold both into the existing debounced refetch. |
||
|
|
77dcd07ecd |
feat(web): graph search fly-to + collection/status/role filters (TASK-1735) (#703)
* feat(web): graph search fly-to + collection/status/role filters (TASK-1735) Toolbar grows a type-ahead search (ref/title over the post-filter node list; ArrowUp/Down + Enter picks, Escape closes without stealing the page's deselect) that routes through the existing selectNode() — same camera fly-to, highlight, and detail card as a click. Client-side filters subset the rendered graph: collection chips with palette dots, status chips, and a role select (hidden when no node carries a role; the graph endpoint now emits the assigned agent-role slug per node). Edges survive only when both endpoints do; counts read "X of Y" while filtered. Workspace switch resets filters; show-completed doesn't. Filter changes deselect so a vanished node can't strand focus mode. New GraphToolbar.svelte owns the presentational toolbar; the page owns authoritative filter state (CONVE-1688 discipline unchanged). Parent: PLAN-1730. * fix(web): close graph search dropdown on blur per Codex review (round 1) The dropdown opened on focus/input but only closed on pick or Escape, leaving stale results floating over the canvas after clicking away. The result buttons already pick on mousedown+preventDefault, so the input never blurs mid-pick — a plain onblur close is safe. * fix(web): gate search Escape on dropdown visibility per Codex review (round 2) Escape in a focused-but-empty search now falls through to the page-level deselect instead of being swallowed by the searchOpen flag. |
||
|
|
1c3db435a3 |
feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) (#702)
* feat(web): graph focus interaction — fly-to, neighborhood highlight, detail card (TASK-1734) Clicking a node now enters focus mode instead of navigating away: the camera flies to the node (800ms, standard distance-ratio pattern with at-origin guard), its neighborhood (any shared edge type) keeps full color while everything else dims to low-alpha, adjacent links brighten and the rest fade hard. A DetailCard slides in from the right: collection dot + ref + title, status pill (terminal styling), child count, relative updated-at, plus priority/assignee fetched lazily via the items API (stale-select token), and the "Open item" button carrying the old click-through navigation. Deselect via background click, Escape (only when a selection is active), or automatically when the workspace/show-completed payload changes. Selection sets stay plain non-reactive lets per CONVE-1688; accessor re-evaluation is explicit via graph.refresh(). Parent: PLAN-1730. * fix(web): focus-mode link adjacency vs mutated endpoints per Codex review (round 1) The force layout mutates link source/target from ref strings into node objects after ingest, so linkColor's adjacency check against selectedRef silently failed once the simulation ran. Preserve the raw refs as sourceRef/targetRef at mapping time and compare those. |
||
|
|
db3917f6d2 |
feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733) (#701)
* feat(web): /graph route MVP — lazy-loaded 3D force graph (TASK-1733)
New full-viewport graph page at /{username}/{workspace}/graph rendering
the TASK-1731 endpoint via 3d-force-graph. Three.js loads only through
a dynamic import inside onMount, landing in its own ~1.3MB chunk
referenced solely by the graph route node — entry bundle unchanged.
Node color = collection (local hex palette; the chart PALETTE's CSS
vars can't reach WebGL), node size = 1 + 2×child_count, blocks edges
red with directional arrows, structural links brighter than soft ones.
Click navigates to the item page (ResolveItem accepts refs in the slug
param). Active items by default with a "Show completed" toggle that
refetches in place; workspace switches refetch with a stale-response
guard. Teardown via _destructor + ResizeObserver disconnect.
Nav: 'graph' added to destinations.ts (NavKey, RESERVED_SLUGS, primary
destinations, getActiveKey) and a Sidebar entry after Insights — the
mobile More sheet picks it up from the shared source automatically.
Parent: PLAN-1730.
* fix: clear stale canvas on workspace switch + reserve 'graph' collection slug per Codex review (round 1)
1. The renderer-sync effect now pushes empty graphData when the
reactive payload is null (workspace switch in flight / load error)
instead of early-returning — the previous workspace's nodes no
longer linger behind the loading overlay.
2. 'graph' added to reservedCollectionSlugs so a collection can't
shadow the /{username}/{workspace}/graph route, matching the
frontend's RESERVED_SLUGS.
|
||
|
|
a20095e870 |
feat(web): graph TS types + API client method (TASK-1732) (#700)
GraphNode / GraphEdge / GraphResponse types mirroring the
GET /workspaces/{ws}/graph payload (TASK-1731), and
apiClient.graph.get(ws, includeTerminal) to fetch it.
Deliberately web-only: no CLI command, no MCP action — the graph
payload is a rendering data feed for the upcoming /graph route, not
an agent surface.
Parent: PLAN-1730.
|
||
|
|
299119403c |
test(e2e): blog screenshot capture for BLOG-1704 (pad v0.7) (#696)
Adds a PAD_BLOG_SCREENSHOTS-gated describe block that seeds a tasks collection, creates a public collection share link, and captures the /s/<token> board view — the source for the v0.7 post's cover/og. |
||
|
|
5c491b78b5 |
feat(web): mobile bottom navigation (PLAN-1694) (#692)
* feat(web): mobile bottom navigation + quick-capture (PLAN-1694)
Add a mobile-only persistent bottom navigation bar that coexists with the
existing TopBar. Five slots: Dashboard, Search, Quick-capture (center +),
Activity, More. The "More" slot opens a BottomSheet with the sidebar's
overflow destinations + collections; the center action opens a quick-capture
sheet that creates an item via the existing API.
- New shared nav source (lib/nav/destinations.ts) consumed by both the
desktop Sidebar and the new BottomNav/More sheet so the two can't drift;
Sidebar refactored to derive active-state from it (lossless).
- BottomNav mounts in the workspace layout (workspace-only), shows only on
mobile (uiStore.isMobile, ≤768px), and toggles body.has-bottom-nav so
app.css reflows .main-content above the fixed bar.
- iOS: app.html viewport-fit=cover + env(safe-area-inset-bottom) padding.
- z-index 40 (above TopBar/sidebar, below sheets).
Header retirement + full "You" (workspace/account) consolidation are
deliberately deferred to a human design checkpoint (DR-3).
Verify: web npm run check (0 errors) + npm run build (✓). Codex review loop
clean (1 P2 fixed: quick-capture re-defaults a stale collection slug).
Refs PLAN-1694 (TASK-1695, TASK-1696, TASK-1697, TASK-1698); parent ROADM-28.
* feat(web): retire mobile header in-workspace, add You sheet + contextual bar (PLAN-1694)
Complete the mobile nav vision on top of the bottom bar: inside a workspace
the global mobile header is gone, replaced by the BottomNav + a "You" sheet +
a contextual back/title bar on detail screens.
- BottomNav 5th slot More → You (👤). New YouSheet composes the workspace
switcher, the nav overflow, and the full account menu carried over from the
retired TopBar (Workspaces/Settings/Billing/Admin/theme/Resources/Connect/
Sign out) so nothing is lost.
- Root layout gates <TopBar mobile /> on !inWorkspace (page.params.workspace),
keeping it for the non-workspace picker/home which has no BottomNav; the
.app-layout top offset now applies only via body.has-mobile-topbar.
- New MobileContextBar: fixed back+title bar shown only on detail screens
(path depth ≥2); body.has-context-bar reflows .main-content top. Root/tab
screens render full-height (reclaimed space).
Codex consensus + review clean (no P1). P2s fixed: safe in-app-history check
via afterNavigate (deep-link fallback to parent URL), humanized title fallback
for pages that don't wire titleStore, and the You sheet closes on navigation
so a workspace switch dismisses it.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1699, TASK-1700); parent ROADM-28.
* feat(web): redesign mobile nav into Workspace + You docked sheets (PLAN-1694)
Address design feedback: the previous "You" sheet reused common/BottomSheet
(covered the nav bar) and embedded the old WorkspaceSwitcher (plain, unadapted).
Replace it with two purpose-built surfaces that dock ABOVE the nav.
- DockedSheet (new): bottom sheet anchored above the bottom nav — backdrop
stops at the nav's top edge so the bar stays visible + tappable and the
active slot stays lit. ~2/3 height, grab handle, slide-up, swipe-down /
tap-out / Escape to dismiss. No more full-screen overlay covering the nav.
- Bottom-nav slot 1 Dashboard → Workspace (icon = current workspace avatar),
opening WorkspaceSheet: a designed switcher card (inline-expand list) +
Navigate tile grid + Collections.
- "You" slot → YouSheet, now account-only: profile header, theme toggle,
account Settings / Workspaces / Billing / Admin / Connect / Resources /
Sign out. Search / + / Activity unchanged.
- New avatar util (avatarColor/avatarInitial) for workspace + user avatars.
Splits the overstuffed single sheet into two focused surfaces (where-am-I vs
me) and removes the reused WorkspaceSwitcher/BottomSheet. Codex review CLEAN.
Verify: web npm run check (0 errors) + npm run build (✓).
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
* feat(web): toggle Workspace/You sheets on repeat nav tap (PLAN-1694)
Tapping the Workspace or You nav slot a second time now closes its open
sheet (tap to open, tap again to close). Opening one still closes the other.
The docked backdrop stops above the nav, so the slot button stays tappable
while its sheet is open.
Refs PLAN-1694 (TASK-1701).
* feat(web): dock mobile search to match the nav sheets (PLAN-1694)
The CommandPalette was the last mobile surface that didn't match — a
full-screen takeover (square, no handle, hid the nav). Give its mobile
presentation the same docked treatment as the Workspace/You sheets;
desktop ⌘K is untouched.
- Mobile: dock the palette above the bottom nav (backdrop stops at the nav's
top edge so the bar stays visible), rounded top, grab handle with
swipe-down-to-dismiss, tap-outside to close, no X. Lift above the nav only
when it's present (:global(body.has-bottom-nav)); flush to bottom on the
non-workspace picker. Keyboard handling preserved: input stays at the sheet
top, .results is the sole scroll area, min-height keeps the input clear of
the keyboard when empty.
- BottomNav: Search slot toggles (tap to open, tap again to close) and lights
up while open; the three surfaces (Workspace / You / Search) are now
mutually exclusive.
Codex review CLEAN. Verify: web npm run check (0 errors) + build (✓).
Note: on-screen-keyboard feel should get an on-device smoke test.
Refs PLAN-1694 (TASK-1701); parent ROADM-28.
|
||
|
|
72d8963c4c |
fix(timeline): resolve collab-snapshot diffs + collapse autosave bursts (BUG-1612) (#691)
Item timelines showed two collab-snapshot problems:
1. Artifacts: the timeline endpoint (ListItemVersionsBeforeTime) served
diff versions unresolved, so TimelineVersionCard fed raw diff-match-patch
patch text into DiffView. Add GET /items/{slug}/versions/{versionID}
(handleGetItemVersion -> Store.GetItemVersionResolved) and have the card
lazily fetch resolved content the first time a diff version is expanded.
2. Clutter: every ~5s web-editor autosave flushes a collab-snapshot version.
buildTimeline now collapses uninterrupted collab-snapshot bursts (within
10 min, no intervening event) to their newest entry, and the source badge
renders as "Autosave" instead of the raw slug.
Adds TestCollapseAutosaveBursts. Known limitation (accepted): collapse is
page-local, so a 150+ cross-actor autosave chain can leak one row per
"Load more" page — gated by the 1h version throttle, degrades gracefully.
|
||
|
|
d2e80efefc |
fix(web): reflow page content when sidebar collapses (BUG-1651) (#689)
Desktop .sidebar.collapsed only applied translateX(-100%), which slides the sidebar off-screen but keeps its column reserved in the .app-shell flex row — .main-content (flex: 1) had nothing to expand into, leaving a blank gap. Release width/min-width on desktop-collapsed so the flex row reflows; scoped :not(.mobile) since the mobile sidebar is position: fixed and uses its width to drive the slide-in drawer. Width animates alongside the existing transform transition for a smooth close. |
||
|
|
a5c7fc986e |
fix(attachments): grant-aware upload auth so share-link editors can attach (BUG-1661) (#688)
handleUploadAttachment gated on requireMinRole("editor") — a workspace-level
check — but the editor and comment composer offer the paste/drop upload
affordance based on grant-aware edit permission. A grant-based editor (guest
with an item/collection edit grant via a share link, no workspace editor role)
could type/post but hit 403 on upload.
Server: read ?item_id early (before spooling the body); when present and
resolvable, authorize via requireEditPermission against the item's grant chain,
else fall back to requireMinRole("editor") for free-floating uploads (new-item
creation, storage settings). Reordered the nil/getWorkspaceID checks above auth.
Client: upload() now also sends item_id as a query param so the server can
authorize before spooling. Threaded the item UUID through Editor.svelte (both
mount sites) and CommentEditor.svelte (ItemTimeline composer + the 3
TimelineCommentCard composers via comment.item_id).
Test: TestUpload_GrantBasedEditorCanAttach — guest with an item edit grant gets
201 with ?item_id and 403 without it (confirms the editor-role fallback didn't
widen access).
|
||
|
|
034b540a77 |
fix(web): de-reactify open-transition trackers in 3 more modals/banners (#687)
Sweep follow-up to BUG-1687. ConnectBanner, CreateCollectionModal, and ConnectWorkspaceModal each tracked their open→close transition with a `$state` variable (`prevOpen`/`lastOpen`) that their `$effect`/`$effect.pre` both read and wrote — the same self-invalidating pattern that silently wedges Svelte's effect scheduler in production builds on close. Make each tracker a plain `let` (they're only used for edge-detection inside the effect), so the effects depend solely on `open`/`connectOpen` and never re-trigger themselves. Found via a codebase-wide audit; these were the only other genuine matches. |
||
|
|
a4e0eb13d8 |
fix(share): stop ShareDialog effect from wedging the scheduler in prod (#686)
ShareDialog tracked the open transition with `let prevOpen = $state(false)` and an `$effect.pre` that both read and wrote `prevOpen` — a self-invalidating effect. In dev this trips `effect_update_depth_exceeded`; in a production build the guard differs and it silently wedges Svelte's global effect scheduler the moment `open` flips on close. Result: after opening+closing the share dialog, effects stop flushing app-wide — clicks change the URL but the view never re-renders (no error, no CPU spin). Make `prevOpen` a plain variable (it's only used for edge-detection inside the effect), so the effect depends solely on `open` and never re-triggers itself. |
||
|
|
d29392443a |
feat(share): inline read-only row expand in public shared view (TASK-1684) (#685)
* feat(share): inline read-only row expand in public shared view (TASK-1684)
Clicking an item row/card in the public `/s/[token]` collection view now
expands the item's fields + sanitized markdown content INLINE on the same
page — no navigation. Items aren't individually shared, so a link would 404
or bypass item-level share ACLs; the expansion is gated by the same share
link and is strictly read-only.
- New PublicItemExpansion panel renders fields + content; it does NOT
sanitize — it receives pre-sanitized HTML from the route's single
marked()+DOMPurify pipeline (the same one the single-item share view uses),
so there's no new {@html} XSS surface.
- Wired `expandable`/`onactivate`/`expandedKey`/`renderContent` through
PublicCollectionView → board/list/table renderers. Toggle on re-click.
- Keyboard accessible: Enter/Space toggles, aria-expanded + aria-controls on
each interactive row/card.
- Works across all three views: board card, list row, and table row (the
table panel spans the full grid width as a presentation row).
- Per-item sanitized HTML is memoized; expansion collapses on view switch.
Parent: PLAN-1677.
* fix(share): render literal field values verbatim, label-format only categorical fields per Codex review (round 1)
formatLabel() was title-casing/de-hyphenating every expanded field value,
corrupting literals like dates (2026-05-31), slugs, IDs, and URLs. Now only
status/priority/select values get label-formatting + color; all other fields
render verbatim, matching the single-item share view.
|
||
|
|
b422988601 |
feat(share): schema-driven colors + empty/large-collection states (TASK-1683) (#684)
Phase 3 polish for the public shared collection view: - Schema-driven status/select colors via fieldValueColor(): canonical open/in_progress/done/blocked palette matches the owner exactly, and a custom terminal option (schema terminal_options, e.g. shipped/closed) now reads as "done" green in cards, list rows, table cells, and board column accents — the bare literal switch missed these. Table colors any select-type column, not just status/priority. - Polished empty state (icon + title + hint, dashed card) shared across all three view types. - Large-collection cap: PUBLIC_ITEM_CAP=200 with a visible "showing N of M" banner (no silent truncation). Applied after saved-view filtering so the count reflects the visible set. - Documented the saved-view sort deferral: payload carries no sort_order/timestamps, so list_sort_by stays a no-op (owner's sort_order order is already honored); a faithful field sort is a backend/payload decision. Parent: PLAN-1677. |
||
|
|
d4ff473715 |
feat(share): read-only view switcher on shared collection route (TASK-1682) (#683)
* feat(share): read-only view switcher on shared collection route (TASK-1682) Add a read-only Board/List/Table view switcher to /s/[token]. Anonymous viewers can toggle the presentation of a shared collection; the choice drives PublicCollectionView's `view` prop, overriding the owner's settings.default_view (which is the initial selection, falling back to 'list'). Saved views from the share payload (collection.views) surface as selectable chips alongside the three base types; selecting one resolves through its view_type (kanban -> board) to a base renderer. Purely presentational — no mutation affordances. Selection persists via the ?view= URL param + localStorage keyed by share token, mirroring the logged-in collection page's saveViewMode/loadSavedViewMode pattern (read-only context, so no server save). URL precedence: ?view= > localStorage > owner default. Parent: PLAN-1677. * fix(share): apply saved-view config (grouping + filters) per Codex review (round 1) Saved-view chips previously only pinned the base view_type, ignoring the view's config — so a public saved view could show the whole collection in the wrong grouping. Now overlay the saved view's config onto the rendered output (read-only): config.group_by maps to board_group_by/list_group_by per active base type, and config.filters (eq/in) narrow the item set. Render path feeds derived parsedCollection/parsedItems to PublicCollectionView. Parent: PLAN-1677. * fix(share): graceful filter skip + correct URL persistence per Codex review (round 2) - Saved-view filters now skip fields absent from the public payload (share items carry only `fields`, not top-level tags/parent/phase), so a saved view filtering on tags no longer hides the entire collection; it degrades to showing the superset. - syncUrl drops ?view= only when the base selection equals the owner default (not hard-coded 'list'), so picking List on a board-default share produces a link that reproduces the choice. Parent: PLAN-1677. * fix(share): schema-based filter evaluability + URL normalization per Codex review (round 3) - Decide saved-view filter applicability from the public collection schema, not item values: a filter on a schema field stays applied even when no shared item sets it (returns none, matching the owner). Only fields genuinely absent from the public schema (tags/parent/phase) are skipped. - initSelection normalizes the URL after resolving, so a stale/invalid ?view= (e.g. deleted saved view) no longer leaves the address bar describing a view the page isn't rendering. Parent: PLAN-1677. |
||
|
|
be53856223 |
feat(share): include saved views in collection share payload (TASK-1681) (#682)
* feat(share): include saved views in collection share payload (TASK-1681)
Expose the collection's saved views on the public /s/{token} payload so the
read-only view switcher (TASK-1682) can render and toggle them. Fetched via
Store.ListViews (ordered by sort_order) and projected to a public shape
under collection.views — name, slug, view_type, config (parsed object),
is_default, sort_order — with internal UUIDs and timestamps stripped.
Always emits an array (never null); empty when the collection has no saved
views, so the switcher falls back to settings.default_view.
Extends the SharePayload TS type with PublicShareView + an optional
collection.views array (additive) for TASK-1682 to consume.
Parent: PLAN-1677.
* fix(share): pin distinct view sort_order in test per Codex review (round 1)
CreateView inserts sort_order=0 and now() is second-granularity, so the two
test views could tie on (sort_order, created_at) and SQL could return either
order, flaking the position-based assertion. Set explicit sort_order 0/1 and
assert on it.
Parent: PLAN-1677.
|
||
|
|
ae055ab412 |
feat(share): render matching view on /s/[token] (TASK-1680) (#681)
Wire PublicCollectionView into the public share page so a shared
collection renders the owner's chosen view type (board/list/table,
falling back to list) instead of a hardcoded flat list. The raw
`collection` + `items` branches of the share payload are passed
straight through — the component parses settings/schema/fields
defensively. expandable={false} for now (inline expand is TASK-1684).
Add a typed SharePayload (+ PublicShareCollection/Item/Settings) to
$lib/types and annotate api.share.get's return type to reflect the
enriched payload (collection.settings, collection.schema,
items[].content).
Existing share-page chrome (header/footer/password/auth states) is
untouched; only the collection BODY rendering swaps. Dead collection
row/header CSS removed.
Parent: PLAN-1677.
|
||
|
|
a58c64fb07 |
feat(web): public read-only collection view renderers (TASK-1679) (#679)
* feat(web): public read-only collection view renderers (TASK-1679)
Adds purpose-built read-only renderers for the public share page that
match the owner's view TYPE (kanban/list/table) and grouping, styled for
an anonymous external audience — no app chrome, no edit/drag/create
affordances, no internal app links.
New components under web/src/lib/components/share/:
- shareView.ts — payload types + DEFENSIVE parsers (settings/schema/
fields tolerated as JSON string OR object, since TASK-1678 ships in
parallel) + label/status/priority color + grouping helpers lifted
from the in-app vocabularies (ItemCard / BoardView / ListView).
- PublicItemCard.svelte — inert read-only card (board).
- PublicBoardView / PublicListView / PublicTableView — the three view
renderers; board groups by board_group_by|status, list optionally
groups by list_group_by, table mirrors TableView's CSS-grid layout.
- PublicCollectionView.svelte — entry point that parses the raw payload
and switches on settings.default_view, delegating to a leaf renderer.
Why: the share page currently renders a hardcoded flat list, ignoring
the owner's chosen view. These components are the render layer; wiring
into /s/[token] against the merged payload is TASK-1680. Row/card
components carry an optional expandable + onactivate contract so the
inline read-only expand (TASK-1684) can be added without a rewrite.
Not wired into any route yet; npm run build passes.
Parent: PLAN-1677.
* fix(web): stable unique each-keys for public renderers per Codex review (round 1)
PublicItem now carries a `key` assigned at parse time from the item's
payload position (ref-prefixed when present). The board/list/table
renderers key their {#each} blocks on it instead of `ref || title` —
refs may be empty and titles aren't unique, so the old key could collide
for two same-titled unprefixed items and break rendering/state.
Parent: PLAN-1677.
|
||
|
|
634e834f22 |
feat(board): Trello-style inline draft card creation (TASK-1676) (#678)
* feat(board): Trello-style inline draft card creation (TASK-1676) Replace the lane `+` direct-navigate (TASK-1671) with an inline draft card. Clicking `+` (or "Add item here") opens an editable card in the lane — no item exists yet. Enter creates it (group value pre-filled) and opens it; blur keeps the card without saving; Escape hides it but retains the text so reopening restores it (until reload). Drafts are per-lane and in-memory. A beforeNavigate guard intercepts in-app navigation while any lane has unsaved draft text and shows a Save / Discard / Stay dialog: Save creates all pending drafts (in place, no navigate) then proceeds, Discard drops them, Stay cancels. Reload/tab-close aren't guarded (nav.to is null) — drafts intentionally live only until reload. quickCreateInColumn now takes (groupValue, title, navigate) and returns the created item / throws so the draft can be restored on failure. The draft card renders above the dndzone so it isn't draggable. * fix(board): lift draft state to page + clear-each-on-save per Codex review (round 1) (1) BoardView unmounts on a board↔list view switch, which destroyed unsaved inline drafts and bypassed the leave guard. Move the draft state (draftText/draftOpen) to the page and bind it into BoardView, and move the beforeNavigate guard + Save/Discard/Stay dialog to the page (always mounted). A draft now survives view switches (state persists; the card just isn't rendered off-board) and the guard fires regardless of view. (2) leaveSaveAll cleared all drafts only after the whole loop, so a retry after a partial failure re-created already-saved drafts. Clear each draft as its create succeeds. * fix(board): only guard true leaves, not same-page URL syncs per Codex review (round 2) beforeNavigate fired on every goto while a draft existed — including the replaceState query/view-state syncs from updateUrlFilters (view toggle, filters, search). Switching Board→List or tweaking a filter wrongly opened the Save/Discard dialog. Skip navigations whose destination pathname equals the current one (internal same-page sync); the draft survives those as page state, so only true page leaves are guarded. * fix(board): don't guard willUnload navigations per Codex review (round 3) For native/external navigations (willUnload), goto can't replay the leave after cancel, so Save/Discard could strand the user. Skip guarding willUnload (and full-unload) navigations — treated like reload, drafts intentionally lost — so the replay path is always a client-side goto. * fix(board): replay Back/Forward with history.go to preserve history per Codex review (round 4) Cancelling a popstate (Back/Forward) navigation then replaying via goto(url) pushed the target as a new history entry, corrupting Back. Capture nav.type/nav.delta and replay popstate with history.go(delta); non-popstate leaves still replay with goto. |
||
|
|
710c76fb66 |
feat(board): per-lane sort override in the kebab menu (TASK-1673) (#677)
Add a "Sort lane by" drill-down to LaneActionsMenu — an ephemeral per-lane override on top of the page-wide sort. BoardView holds the override map (Record<lane, SortMode>, not persisted), applies the effective mode (override ?? page default) per lane in propColumnData, and disables drag per-lane under a non-manual effective sort. The submenu lists the same options as the toolbar (Priority hidden when no priority field) plus a "Page default" entry to clear the override, with a check on the active one. Sort is a view preference, available to everyone — so the kebab now shows for any non-empty lane (not just editors), and the menu's separators are section-gated so nothing dangles when a viewer sees only Sort. |
||
|
|
2cd8932148 |
feat(board): confirm + undo toast for destructive bulk actions (TASK-1674) (#676)
* feat(board): confirm + undo toast for destructive bulk actions (TASK-1674 frontend) Extend the toast store with an optional inline action button (label + onAction) and render it in ToastContainer. Wire Undo onto the two destructive bulk lane actions: - Archive all → "Archived N" with Undo → bulk `restore` of the affected ids (the backend op from the companion PR). - Move all to → "Moved N" with Undo → bulk move back to the source lane (all lane items shared its status). runBulk gains an optional undo callback (invoked with the affected ids) and gives undo-bearing toasts a longer 7s dwell. Archive keeps its in-menu count confirm (TASK-1672); the undo toast is the new safety affordance replacing the old single-item inline confirm. * fix(board): undo targets the original workspace per Codex review (round 1) The 7s undo toast is global and runBulk read the live wsSlug, so navigating to another workspace before clicking Undo would target the wrong one. Thread an explicit captured `ws` through runBulkOn; the archive/move handlers capture wsSlug at action time and the undo closure reuses it, so a deferred undo always hits the workspace the action ran on. Immediate (non-undo) actions keep a thin runBulk bound to live ws. |
||
|
|
1d9a611508 |
feat(api): bulk restore op for undo (TASK-1674 backend) (#675)
Add a 'restore' verb to the bulk endpoint so an undo of a bulk archive is one call. The loop resolves include-deleted for restore (archived rows are hidden from ResolveItem); applyBulkOp calls store.RestoreItem, mapping UNIQUE-constraint races to a conflict and sql.ErrNoRows to not-found, and logging action="restored". Also make ResolveItemIncludeDeleted UUID-aware (mirrors ResolveItem) so restore resolves by the ids the bulk response returns. Adds 'restore' to the TS BulkItemOp / BulkItemsRequest union and a Go test (archive → restore round-trip by id). |
||
|
|
99e8802095 |
feat(board): wire bulk lane actions via the bulk endpoint (TASK-1672) (#674)
* feat(board): wire bulk lane actions via the bulk endpoint (TASK-1672)
Extract LaneActionsMenu.svelte — a drill-down kebab menu — and populate
it with the full mutation set, each operating on the lane's CURRENTLY-
FILTERED items via api.items.bulk:
- Archive all (count + "(filtered)" in the confirm)
- Move all to ▸ (other status lanes; status-grouped boards only)
- Tag all ▸ (free input + workspace tag suggestions)
- Untag all ▸ (tags present on the lane's items)
- Set priority ▸ (the priority field's options)
- Assign all ▸ (workspace members by name)
Page handlers funnel through a shared runBulk() that calls the bulk
endpoint once, deltaSyncs, and toasts the updated/failed counts; it
returns the affected ids for TASK-1674's undo. BoardView threads the
lane's items + member/tag data + callbacks into the menu; the old
inline menu markup/styles (TASK-1671) move into the component. All
actions canEdit-gated. Drill-down clicks stopPropagation (the Svelte 5
same-click detach fix). Move scope = status, Assign = members only.
* fix(board): chunk bulk lane actions at the 1000-id server cap per Codex review (round 1)
The bulk endpoint rejects >1000 ids; the old per-item archive loop had
no ceiling, so a >1000-item filtered lane would fail every action with
"too many items". runBulk now chunks ids at 1000 (one bulk call per
chunk — a few SSE events instead of thousands) and aggregates the
updated/failed counts across chunks.
* fix(board): finalize partial bulk results when a chunk throws per Codex review (round 2)
A thrown chunk returned early, skipping deltaSync and the count toast —
leaving the UI stale after earlier chunks had already mutated items
server-side, behind a generic error. Move the try/catch inside the loop
(break on throw), then always deltaSync (when anything succeeded) and
toast the partial result: 'Verb N, M failed' on partial success, the
server error message only when nothing succeeded.
* fix(board): gate bulk lane actions on owner/editor role per Codex review (round 3)
canEditThisCollection is grant-aware (true for collection edit grants),
but the bulk endpoint requires workspace owner/editor (TASK-1668), so a
grant-only editor saw the lane bulk actions and got a 403 per click.
Gate the bulk callbacks (archive-all/move/tag/untag/set-priority/assign,
in both Board and List) on a new canBulkEdit = owner|editor role. The
single `+` create stays grant-aware (canEditThisCollection). LaneActions-
Menu's action callbacks are now optional and each entry hides when its
callback is absent, so a grant-editor sees only "Add item here".
* fix(board): drive lane-action visibility off callbacks, not canEdit per Codex review (round 4)
The kebab wrapper was still inside {#if canEdit} (= canEditThisCollection),
so an owner/editor without a collection edit grant (canBulkEdit true,
canEdit false) couldn't open the menu at all. Show the `+` when
onCreateInColumn is wired and the kebab when any menu action is wired
(hasMenuActions) — each callback already encodes its own permission.
Same fix for ListView's archive-group button (gate on onArchiveGroup
alone, not canEdit && onArchiveGroup).
* fix(board): lane-aware kebab visibility per Codex review (round 5)
The kebab showed whenever any bulk callback existed, but the bulk menu
entries only render for a non-empty lane — so a role-only bulk editor
(no onCreateInColumn) saw a ⋯ that opened an empty panel on empty
schema lanes. Show the kebab only when onCreateInColumn is wired OR the
lane has items AND a bulk action is wired.
|
||
|
|
520380946e |
feat(collections): page-wide sort control + priority-weight helper (TASK-1670, closes IDEA-1648) (#673)
Add a Sort dropdown to the collection toolbar (Manual, Priority, Recently updated, Created, A→Z) applied within each lane/group in both BoardView and ListView. A shared helper (lib/collections/itemSort.ts) builds the comparator; the priority weight reads the `priority` select field's own options order, so high/medium/low and must/should/nice both rank naturally (top option = highest) with no hardcoded map. - 'manual' (default) resolves to the stored sort_order — prior behavior. - Non-manual sorts disable item drag (like the preserveOrder seam): a comparator-ordered lane can't accept a drag, so DnD is suppressed. - 'Priority' is hidden when the collection has no priority field; a stale persisted 'priority' falls back to 'manual'. - Sort persists per collection in localStorage (mirrors view mode). - Table view keeps its own column sorting; the control is board/list only. |
||
|
|
9dd4fa653e |
feat(board): lane-header + add button + kebab menu shell (TASK-1671) (#672)
* feat(board): lane-header + add button + kebab menu shell (TASK-1671, folds IDEA-1159) Replace the hover-only .archive-col-btn in BoardView lane headers with two always-visible, touch-sized affordances: - `+` add-into-this-lane button → onCreateInColumn(colValue), which quick-creates an item with the lane's group field pre-filled (status or a custom board_group_by) and opens it. - `⋯` kebab → a lane menu (the new home for column actions). Shell carries "Add item here" + "Archive all (N)" with an in-menu confirm; move/tag/priority/assign land in TASK-1672. One menu open at a time, dismissed on outside click (QuickActionsMenu pattern). Both affordances are canEdit-gated. Touch targets bump to 32px ≤768px. * fix(board): stopPropagation on lane-menu clicks per Codex review (round 1) Clicking "Archive all" set confirmArchiveColumn, which re-rendered and detached the clicked button before the event bubbled to the window outside-click handler — closest() on the orphaned node returned null and slammed the menu shut, so the confirm never showed. Stop propagation on the kebab toggle and every in-menu click (the documented Svelte 5 same-click bubbling fix from console/+layout.svelte). |
||
|
|
2721d890ec |
feat(web): bulk-mutation API client method + types (TASK-1669) (#671)
* feat(web): bulk-mutation API client method + types (TASK-1669)
Add api.items.bulk(ws, data) hitting POST /workspaces/{ws}/items/bulk
(TASK-1668), plus types in lib/types: BulkItemOp, a discriminated-union
BulkItemsRequest (each verb requires only its own params), and
BulkItemOutcome / BulkItemFailure / BulkItemsResponse for the per-row
outcome envelope. Wire contract matches the backend struct field-for-
field; `details` is unknown (server json.RawMessage).
Unblocks the lane-header bulk-action UI (TASK-1672).
* fix(web): assign bulk op ids are string-only, not nullable per Codex review (round 1)
The server treats JSON null as absent (mirrors ItemUpdate) and rejects an
assign with no real id and no clear flag, so a nullable type would let a
caller type-check `assigned_user_id: null` into a 400. Type these string-
only and document that clearing goes through the clear flags.
|
||
|
|
57995c5898 |
fix(sync): moved-out tombstones for cross-visibility collection moves (BUG-1675) (#670)
* fix(sync): emit moved-out tombstones for cross-visibility collection moves (BUG-1675) /items-changes filtered deltas by an item's CURRENT collection, so an item moving from a collection a restricted member can see into one they can't vanished with no eviction signal — the stale, now-unauthorized row lingered in their local cache until a full rebootstrap. Server: - store.ListMovedOutSince: finds items that changed since the cursor, are now outside the caller's visible scope, and have a 'moved' activity FROM a collection the caller CAN see. Returns id+seq only — no destination data leaks (the caller has read access to the source). - handleListItemsChanges merges these in as moved_out tombstones, then seq-sorts + caps the combined stream so pagination stays gap-free. - Bulk collection moves now log a proper 'moved' activity with from/to collection slugs (mirroring handleMoveItem) — the signal the tombstone query reads. Previously they logged generic 'updated'. Client: - ItemChangeRow gains moved_out; applyDelta hard-evicts those ids from RAM + search and queues the IDB delete into the SAME atomic cursor-advance tx (persistDelta gains removeIds) so it can't resurrect on warm boot. Full members (nil visibility) skip the extra query entirely — the path only runs for restricted members/guests. Tests: store-level matrix (ListMovedOutSince), end-to-end restricted member /items-changes tombstone, bulk-move 'moved' activity logging. * fix(sync): tie moved-out tombstone to the move event's seq per Codex review (round 1) Keying the tombstone on the item's CURRENT seq meant any later change while it sat in a hidden collection re-emitted a moved_out row — leaking that an invisible item keeps mutating, and never settling. Stamp the post-move seq into the 'moved' activity metadata (both single + bulk move paths) and key the tombstone on THAT seq: it fires once, for the move that crossed the visibility boundary, and the cursor settles past it. Moves logged before the seq stamp are skipped (evict on rebootstrap) rather than risk the re-fire. Test: re-fire regression (a post-move hidden-collection update must not re-emit the tombstone). * fix(sync): page moved-out tombstones by move seq, not current seq per Codex review (round 2) Ordering/capping candidates by the item's current seq could strand an item that moved out early (low move seq) but later churned in the hidden collection (high current seq): it fell past the limit while the cursor advanced beyond its move seq, never to be emitted again. Collect all eligible rows, keep the earliest qualifying move per item, sort by move seq, then apply the limit at a move-seq boundary so dropped rows re-fetch cleanly on the next poll. Test: 3 items move out ascending; the earliest churns to a high current seq; limit=2 must still return the two smallest move seqs, then the third on the next page with no gap. * fix(sync): durable item_collection_moves table for moved-out detection per Codex review (round 3) Moved-out detection read the 'moved' activity row, which is written after the move commits and best-effort (errors discarded) — so a delta poll racing the audit write, or a failed write, could advance the cursor past the move seq and strand the unauthorized item forever. Record every cross-collection move in a new item_collection_moves table inside the SAME transaction as the move (MoveItemWithPreCheck), carrying the workspace seq the move assigned. ListMovedOutSince now reads that table — fully SQL/indexed (from_collection_id IN visible, MIN(seq) for multi-hop, current-collection NOT IN visible), no JSON parsing, no best-effort dependency. The 'moved' activity stays for audit only. Migration 066 adds the table + indexes. Tests updated to rely on the durable record (MoveItem writes it) rather than hand-logged activity. * fix(sync): add Postgres migration for item_collection_moves per Codex review (round 4) Postgres reads the separate pgmigrations/ tree, so the SQLite-only migration 066 left item_collection_moves absent on PG deploys — every cross-collection move would fail at the in-tx insert and moved-out queries would error. Add pgmigrations/045 with the equivalent table + indexes. |
||
|
|
dfd3811eee |
feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668) (#669)
* feat(api): bulk-mutation endpoint + single SSE batch event (TASK-1668)
Add POST /workspaces/{ws}/items/bulk accepting item IDs + a verb
(archive, move, tag, untag, set-priority, assign). The lane-header
bulk actions operate on a whole filtered lane, so the endpoint emits
ONE items_bulk_updated SSE event and ONE item.bulk_updated webhook for
the batch instead of per-item fan-out.
Reuses the existing store paths (UpdateItemWithPreCheck / MoveItem /
DeleteItem) rather than re-implementing writes; the open-children
guard runs per status-bearing move exactly as the single PATCH path
does (force-overridable). Per-row failures are collected into the
response envelope (updated/failed/total) rather than aborting the
batch. Editor/owner gated.
Frontend client + TS types follow in TASK-1669; UI wiring in TASK-1672.
Parent: PLAN-1667.
* fix(api): per-item visibility + collection-move guard on bulk endpoint per Codex review (round 1)
- Enforce per-item collection visibility (checkItemVisible) in the bulk
loop so a member with collection_access="specific" can't bulk-mutate
items in hidden collections by guessing refs; report invisible rows as
not-found. Also gate the move target collection on visibility.
- Route bulk collection moves through MoveItemWithPreCheck with the
open-children guard (destination schema), closing the bypass where a
collection move + terminal status could mark a parent terminal with
open children. Status-only moves already ran the guard.
- Tests: status-move + collection-move guard coverage (reject + force
override + mutation-safety).
* fix(web): consume items_bulk_updated SSE event per Codex review (round 2)
The bulk endpoint emits one items_bulk_updated event, but the SSE
service only listened for the fixed ITEM_EVENTS list — so a bulk
mutation left other tabs/sessions stale until an unrelated sync fired.
Route the batch event through the existing sync_required path: it
carries item_ids + a max seq but no per-item field payload, so an
incremental /items-changes delta reconciles every affected row by seq.
Broadcast so peer tabs reconcile too.
* fix(api): scope bulk SSE event per-collection, drop item_ids per Codex review (round 3)
The batch event published with an empty Collection, which the SSE
filter treats as workspace-level: restricted members received bulk
events for hidden collections (leaking item_ids/op/count) while guests
with grants were dropped entirely and stayed stale.
Emit one items_bulk_updated event per affected collection with
Collection set, so the existing visibility filter routes it like any
collection-scoped event. Drop per-item IDs from the SSE payload — a
batch can't be item-grant-filtered for guests on a broadcast bus, so
IDs would leak; recipients reconcile via the /items-changes delta,
which is visibility-filtered server-side (Seq carries the cursor). The
webhook (a trusted workspace integration) keeps the full id list.
Test asserts the event is collection-scoped and carries no item_ids.
* fix(api): bulk collection move notifies both source and target scopes per Codex review (round 4)
A cross-collection move only emitted a batch event for the target
collection, so a restricted member watching the source lane wouldn't
reconcile the item leaving it. Notify both the source and target
collection scopes for moves (still no per-item IDs). Test asserts both
events fire.
* fix(api): suppress itemless batch SSE events for item-grant-only subscribers per Codex review (round 5)
A guest/restricted member with only item-level grants in a collection
could still receive the collection-scoped items_bulk_updated event
(itemless), learning op/count/timing for items they can't see. Extract
the SSE visibility filter into sseEventVisibleFor and add a rule:
itemless collection-scoped events go only to subscribers with FULL
collection access; item-grant-only subscribers reconcile their granted
items via the next resume/reconnect /items-changes sync instead.
Adds a unit test covering the visibility matrix.
* fix(api): validate status override against target schema on bulk collection move per Codex review (round 6)
A status override on a collection move was applied after MigrateFields
but never validated against the target schema, so an out-of-options
value (e.g. status=bogus) could be written. Run ValidateFields on the
final field map before the move. Test asserts the invalid value is
rejected per-row and the item stays put.
|
||
|
|
1be42e5406 |
feat(collections): multi-select tag filter + per-collection tag counts (TASK-1666) (#668)
Add a TagFilter chip row to the collection page FilterBar — one toggle chip per tag present in the collection, each showing its item count, with OR selection semantics. Tag counts are derived client-side from the local index (no network round-trip) and honor the showArchived toggle. Selection round-trips through the `tags` URL param and the saved-view ViewConfig (rides along as a single `op: 'in'` filter). Implements TASK-1666. |
||
|
|
0d5f96657b |
feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) (#667)
* feat(comments): inline comment/reply editing via CommentEditor (TASK-1665) Completes PLAN-1662. Adds inline edit mode for comments and replies, built on the backend (TASK-1663) + CommentEditor (TASK-1664). - Edit affordance (hover pencil) next to Delete on each comment and reply, shown only when editable: comment.user_id === currentUserId || isAdmin — distinct from canEdit (item perm, which gates delete/reply/react). Mirrors the server's canEditComment; null user_id → admin-only. - Clicking Edit swaps the rendered body for a CommentEditor seeded with the current body; Save calls api.comments.update via an onEdit callback (throws on failure so the editor keeps the draft); Cancel restores. One reply edits at a time (editingReplyId). - Image removal falls out: removing an image is just deleting its node in the editor and saving — no separate endpoint/button. - "edited" marker (· edited, title=updated_at) when updated_at is meaningfully after created_at. Reactions live in separate tables and don't bump updated_at, so it's edit-specific. - handleReply now re-throws so the reply editor also preserves its draft on failure; comment_updated SSE refresh was wired in TASK-1663. Parent: PLAN-1662. * fix(comments): flag edited on any positive updated_at delta per Codex review (round 1) created_at and updated_at are set identically on create, so a strict inequality (not a >1000ms threshold) is the correct 'edited' signal at RFC3339 second precision — the old threshold missed edits exactly 1s later. Same-second edits remain undetectable without an explicit edited_at column; acceptable for v1. |
||
|
|
6ed16ef930 |
feat(comments): lean Tiptap CommentEditor — inline image thumbnails (TASK-1664) (#666)
Replaces the plain-textarea comment composer and reply box with a small WYSIWYG editor so pasted/dropped images render as inline thumbnails instead of `` markdown text. - web/src/lib/components/CommentEditor.svelte: a purpose-built Tiptap instance (NOT the heavy Editor.svelte) — StarterKit basics + Link + Placeholder + tiptap-markdown + the shared attachment pipeline (AttachmentUpload plugin + AttachmentImage/AttachmentChip nodes). No tables/slash/collab/URL-modal. Emits markdown via the markdown storage (round-trips through the nodes' addStorage serializers), so comment.body stays markdown — display, lightbox, search, and orphan-GC are untouched. Wraps the upload fn to track in-flight uploads and gate submit (the plugin doesn't expose its placeholder count). Ctrl/Cmd+Enter submits, Esc cancels (reply mode). - ItemTimeline composer + TimelineCommentCard reply box now render CommentEditor; submitComment/submitReply take the markdown string and throw on failure so the editor preserves the draft. Removed the textarea + commentAttachments paste/drop wiring and now-dead CSS. Inline thumbnails in the editor are capped to match the rendered-comment display. Parent: PLAN-1662. Unblocks TASK-1665 (edit mode reuses this). |
||
|
|
076fb9b2e7 |
feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663) (#665)
* feat(comments): comment editing backend — user_id, UpdateComment, PATCH, SSE (TASK-1663)
Foundation for comment editing (PLAN-1662). No migration — comments.user_id
already exists (012_users.sql) but was never written or exposed.
- Populate user_id on create/reply: CreateComment takes an explicit userID
param (passed from currentUserID by the handlers, not via the request body
so it can't be spoofed). Expose user_id on models.Comment + all comment
SELECTs/scans. The workspace export path is left as-is — imported comments
keep NULL user_id (admin-only edit), matching the pre-identity fallback.
- Store.UpdateComment(id, body): replaces body + bumps updated_at; the
comments_fts_update trigger re-indexes.
- PATCH /workspaces/{ws}/comments/{commentID}: author-or-admin only
(canEditComment), rejects empty body. Editing is an authorship op, distinct
from delete (item editors). NULL user_id → admin-only.
- comment_updated SSE event: broadcast from the handler; added to the web
sse allowlist + ItemTimeline refresh set.
- web: api.comments.update(), Comment.user_id type.
Tests: author edits own (200), non-author non-admin (403), admin edits
anyone (200), empty body (400), NULL-user_id comment is admin-only.
Parent: PLAN-1662.
* fix(account): detach authored comments on account deletion per Codex review (round 1)
Now that TASK-1663 populates comments.user_id (FK to users.id),
DeleteAccountAtomic would fail on the FK for any user who authored a
comment. Null comments.user_id for the user before deleting the row —
comments live on in soft-deleted/other workspaces; the display-name
author is preserved and the comment just becomes admin-only to edit.
Regression test added.
|
||
|
|
4c2c4d1e1d |
feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660) (#664)
* feat(comments): thumbnail comment images + click-to-expand lightbox (IDEA-1660)
Tier 1 + Tier 2 of the IDEA-1650 follow-up.
Tier 1 — thumbnails:
- Thread an image `variant` through the markdown render pipeline
(renderMarkdown → AttachmentRenderContext → resolveAttachmentImage →
renderAttachmentImage), defaulting to thumb-md. Comment + reply bodies
pass thumb-sm (256px) so pasted screenshots fetch the small variant.
- CSS caps inline attachment images to a 280×180 box with cursor:zoom-in.
Tier 2 — lightbox:
- New reusable Lightbox.svelte (common/) — full-resolution overlay,
Esc/backdrop close, ←/→ paging, image counter. Loads the original
(un-variant) blob for full detail.
- ItemTimeline attaches a delegated click handler (via a use: action, so
no a11y lint on the static container) that opens the lightbox on any
img[data-attachment-id], collecting sibling images in the same
comment/reply body for paging.
No backend changes. Tier 3 (dims-via-headers, multi-image grid,
loading=lazy) intentionally deferred.
* fix(comments): keyboard-activatable comment image thumbnails per Codex review (round 1)
Thumbnails come from sanitized {@html}, so they can't be wrapped in a
<button> at render time. Instead make each img[data-attachment-id] a
focusable role=button with an aria-label imperatively (re-applied on
entries change, covering SSE-added comments), and add a delegated
keydown (Enter/Space) alongside the existing click so keyboard users
can open the lightbox.
* fix(comments): re-run thumbnail focusability pass when attachment metadata resolves per Codex review (round 2)
An attachment renders as <img> only after its HEAD-probe metadata
resolves (before that it's a 'missing' placeholder span). The
focusability pass depended only on entries, so it missed images that
appeared on metadata resolution. Add attMeta as a dependency.
|