Commit Graph

440 Commits

Author SHA1 Message Date
xarmian e056e9c3f4 feat(web): NL-canonical playbook invocation in the editor + library (TASK-1860) (#750)
buildTestInvocation now returns a surface-neutral `nl` form ("run the <slug>
playbook …") as the first/canonical field; the playbook editor's
"Test invocation" helper leads with a "Natural language — works anywhere"
block and frames the Claude Code / CLI / MCP forms under "Or use the shortcut
for your agent:".

The library card invocation chip changes from `/pad <slug>` to `▶ <slug>`
with an NL-canonical tooltip that lists the per-surface shortcuts — the web
UI can't know which agent the user runs, so this is honest reframing rather
than a per-tool chip.

Parent: PLAN-1858.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 15:26:38 -04:00
xarmian 408e578567 feat(web): setup-progress checklist on the launchpad (TASK-1857) (#748)
Adds a compact "Setup progress" checklist to OnboardingLaunchpad
(✓ Workspace created · ○ Agent connected · ○ Setup complete) using existing
signals only — agentActive is wired from dashboard.has_agent_activity, no
new backend tracking.

Honesty caveat (documented on the prop): has_agent_activity flips on the
first agent-CREATED item, which also clears needs_onboarding and removes the
launchpad — so the checklist is an orientation device (you're on step 1),
not a live mid-launchpad tracker. A distinct "connected but hasn't acted
yet" signal is deferred to IDEA-1854.

Parent: PLAN-1847 (Phase 3) — final Phase 3 task.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 12:32:23 -04:00
xarmian 4a99f5f213 feat(web): manual escape hatch on the skipped-onboarding board (TASK-1856) (#747)
A user who clicks "Skip setup" on an empty workspace landed on a board with
no collections and no surfaced way to create one (collections lived behind
Settings) — declining an agent was effectively a dead end.

When onboarding is dismissed and the workspace has no user collections, owners
now see an "Your workspace is empty" card with a "+ Create a collection"
button (opens the existing CreateCollectionModal) and a "Show setup guide"
link back to the launchpad. Owner-gated to match the server's
create-collection boundary; hidden the moment any user collection exists.
Non-owners and already-populated boards keep the existing slim reshow link.

Parent: PLAN-1847 (Phase 3).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 12:29:09 -04:00
xarmian fa8064e2b9 feat(onboard): capture workspace intent at creation, warm the onboard run (TASK-1855) (#746)
Intent-as-seed for the onboarding bridge. The workspace `description` column
already existed end-to-end but nothing captured or surfaced it:

- Web: CreateWorkspaceModal gains an optional "What are you tracking?"
  textarea (create-only), sent as `description` on create.
- Bootstrap: AgentBootstrapWorkspace now carries `description` (omitempty,
  additive) so the onboard playbook can read the user's stated intent.
- Onboard playbook: pre-flight reads workspace.description; B1 reflects it
  back ("You mentioned this is for X — let's build around that") instead of
  opening cold with "what is this project?", falling back when absent.

Net effect: a user who types one line at creation gets an onboard interview
that starts warm instead of from zero.

Parent: PLAN-1847 (Phase 3).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 12:25:46 -04:00
xarmian 80a939fe3c feat(web): aha-highlight the first agent-created item (TASK-1853) (#745)
* feat(web): aha-highlight the first agent-created item (TASK-1853)

Closes the sprint-to-aha loop for the onboarding bridge: when an agent
creates the first real item during onboarding, its dashboard card gets a
" your agent just created this" badge + accent, so value lands visibly
for the human in the live launchpad→board handoff.

Detection is a slug-keyed effect on needs_onboarding: when it flips
true→false within the same workspace (driven by the existing SSE→sync→load
path), the current active_items slugs are captured and their cards
highlighted for the session. Scoped tightly to the live transition — a
later page load (needs_onboarding already false) fires no transition, so
routine creates are never highlighted; the slug key prevents a workspace
switch from false-positiving.

Satisfies CONVE-1688 (the effect writes justCreatedSlugs but never reads
it) and CONVE-606 (untrack-wrapped, route-change-aware).

Parent: PLAN-1847 (Phase 2, task C).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* fix(web): key aha-highlight on dashboard data's slug, not route param, per Codex review (round 1)

Round-1 review caught a workspace-switch race: a silent reload leaves the
old dashboard (needs_onboarding=true) in state while wsSlug already flipped,
so the route-keyed edge recorded onboarding=true under the new slug and then
false-positived when the new workspace's data (false) arrived — highlighting
all of the new workspace's items.

Stamp the dashboard with the slug it was fetched for (dashboardSlug) and key
the transition on that. The true→false edge is now only ever computed across
two loads of the SAME workspace's data.

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 11:51:15 -04:00
xarmian ac43e490e1 feat(web): launchpad render-mode for unset-up workspaces (TASK-1852) (#744)
* feat(web): launchpad render-mode for unset-up workspaces (TASK-1852)

While `needs_onboarding` is true, the workspace dashboard rendered as an
empty board with a nudge banner on top — which reads as broken, not new.
Replace that with a dedicated setup launchpad (OnboardingLaunchpad.svelte):
a three-step bridge that routes the user into the agent-driven onboard flow
— ① connect an agent (opens the existing ConnectWorkspaceModal), ② tell it
"set up my workspace" (NL-canonical, with per-surface shortcuts), ③ watch
the result appear live via the page's SSE feed.

It's a render-mode keyed on the existing flag, not a new route: while
needs_onboarding && !dismissed the launchpad renders instead of the board;
"Skip setup" falls through to the board with a reshow affordance; the flag
flips false on the first real item, swapping back to the normal dashboard.

The launchpad supersedes OnboardingNudgeBanner (TASK-1851) — it had no other
consumer, so it's deleted; its NL-canonical copy carries over into step ②.

Parent: PLAN-1847 (Phase 2, task B).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ

* refactor(web): drop unused wsSlug prop from OnboardingLaunchpad

Self-review cleanup: wsSlug was declared in Props and passed by the parent
but never used in the component (it opens the connect modal via callback).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 11:44:40 -04:00
xarmian 1d62035f28 feat(web): NL-canonical onboarding banner copy (TASK-1851) (#743)
OnboardingNudgeBanner hardcoded `/pad onboard`, which contradicted the
NL-canonical messaging shipped in TASK-1849 (and is wrong for non-Claude-Code
agents). Lead with natural language ("say set up my workspace") instead — the
universal trigger that works regardless of which agent the user connects.

ConnectBanner audited — no slug reference, left as-is. Per-surface shortcut
rendering belongs in the connect modal's surface-aware kickoff (TASK-1852),
where the chosen tool is known.

Parent: PLAN-1847 (Phase 2, task A).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-21 11:36:09 -04:00
xarmian 14614c98f7 feat(auth): surface server version on /auth/session (TASK-1839) (#740)
Add the server build version to both the setup-state and authenticated
/auth/session payloads (same source as /health). The mobile shells call
/auth/session on connect; surfacing version there lets them read it in
the round-trip they already make and warn when a server is below their
minimum supported version, without a second request (IDEA-1826).

Keep the web AuthSession TS type in sync (CONVE-1741).
2026-06-21 00:34:25 -04:00
xarmian 22d901c823 fix(auth): unify first-run setup into one browser handoff (BUG-1843) (#739)
On a fresh instance, `pad init` / `pad auth setup` created the admin
account in the browser and dropped the operator on the console, then
printed a SECOND "authorize the CLI" URL back in the terminal that a
user who'd moved to the browser never saw — forcing a ctrl-C + re-run.

Collapse it into a single browser tab: the CLI mints the pending CLI
auth session up front and hands /setup a validated `next=/auth/cli/<code>`
target, so account creation flows straight into the approval page where
the just-bootstrapped admin approves in one click and the CLI connects.

- internal/cli/bootstrap.go: thread `next` into the /setup URL (query
  before the #token fragment); raise bootstrapPollTimeout to 20m to
  match the setup session TTL.
- cmd/pad/main.go: extract pollAndSaveCLIAuth; runBrowserSetup pre-creates
  the session and polls it; `pad workspace init` drives local setup inline.
- cmd/pad/init.go: `pad init` routes through the unified handoff.
- internal/store + internal/server: grant a setup-specific 20m CLI auth
  session TTL when UserCount==0 so the combined create-account + approve
  window can't expire mid-flow; normal logins keep the 5m default.
- web/src/routes/setup: honor a validated local `next` redirect (open-
  redirect guarded), preserved across the token-fragment scrub.

Reviewed via Codex loop (3 rounds → clean).

Claude-Session: https://claude.ai/code/session_01KmxkPxLksjf1pmrZDpsnTJ
2026-06-20 23:51:43 -04:00
xarmian 62fafc49c6 fix(web): kanban board height accounts for workspace top bar (BUG-1844) (#738)
Board-active collection pages hardcoded height:100vh, but the page
lives inside .main-content which is already sized below the workspace
TopBar via flexbox. With the bar visible the board overflowed by the
bar's height (extra scrollable space / too-tall lanes); with it hidden
100vh coincidentally matched. Use height:100% so it fills .main-content
in both states.
2026-06-20 21:57:25 -04:00
xarmian c93f2aa12c feat(web): unified mobile top bar with always-on workspace switcher (IDEA-1835) (#737)
Promote MobileContextBar from a detail-only back+title bar to the persistent
mobile top bar shown on every screen inside a workspace. The collection/item
name now reads the same everywhere and the workspace switcher (moved out of the
bottom-nav sheet) is always visible for at-a-glance context and quick switching.

- MobileContextBar: show on all workspace screens (was depth >= 2); gate the
  back affordance to non-root screens; embed WorkspaceSwitcher on the right,
  restyled onto --bg-tertiary and sized to fill the 44px bar.
- WorkspaceSheet: drop the switcher card + inline list (now in the top bar);
  the "Workspace" bottom-nav slot keeps its label/avatar and Navigate +
  Collections.
- Collection list: hide the now-duplicate in-page collection-name heading on
  mobile; the name lives in the bar.
2026-06-17 21:56:25 -04:00
xarmian 8c8e858e1a feat(web): show archived banner in-place on live archive (TASK-1833) (#736)
Follow-up to TASK-1829 (Codex review of #735). When the open item was
archived live — via the SSE item_archived handler or the sync-resume
deleted path — the detail route redirected back to the collection, so the
new in-place Archived banner only appeared on a fresh direct load. Now both
handlers re-fetch the item (GET returns soft-deleted items with deleted_at,
#733) and render the banner in place.

- SSE item_archived: re-fetch and show the banner instead of goto().
- Sync-resume deleted: re-fetch — an archived item (still resolvable, 200)
  shows the banner; a hard-deleted one (404) still redirects.
- Both keep the prior redirect when mid-edit (saveStatus==='saving' ||
  editingTitle): an in-flight save against an archived row would fail and a
  re-fetch would clobber the editor (the original Codex-round-2 reasoning).
- Race guards mirror the handlers' existing pattern (capture item id before
  await, bail if navigated away). The actor's own archive still navigates
  via handleDelete's goto; this only changes the someone-else-archived-it case.

svelte-check + web build green.
2026-06-15 16:52:35 -04:00
xarmian 372d95cb2a feat(web): archived-item recovery on the item detail page (TASK-1829) (#735)
With GET now returning soft-deleted items read-only (deleted_at populated,
shipped in #733), the detail route can show an archived item instead of a
hard 404. Adds the recovery UI:

- isArchived derived from item.deleted_at; folded into the existing canEdit
  derived so every edit affordance disables while archived.
- A read-only "Archived" banner at the top of the item view (date via the
  file's relativeTime helper) with a Restore button -> api.items.restore,
  then re-fetches the item so the banner clears and editing re-enables.
  Success/error via the existing toastStore; a 409 reclaimed-slug conflict
  surfaces verbatim.
- restoring in-flight flag; handleRestore is a standalone async function
  (not an effect) per CONVE-1688 / CONVE-606.

No API/client/server change — GET is already ungated (#733) and
api.items.restore already existed. Child of BUG-1791 (TASK-1827 in #733,
TASK-1828 in #734).
2026-06-15 16:07:18 -04:00
xarmian 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.
2026-06-08 20:20:46 -04:00
xarmian 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.
2026-06-08 20:03:53 -04:00
xarmian 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.
2026-06-08 19:37:22 -04:00
xarmian 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.
2026-06-08 19:21:05 -04:00
xarmian 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.
2026-06-08 18:42:33 -04:00
xarmian 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.
2026-06-08 18:34:50 -04:00
xarmian 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.
2026-06-08 18:15:39 -04:00
xarmian 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.
2026-06-07 23:27:56 -04:00
xarmian 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.
2026-06-07 12:02:30 -04:00
xarmian 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)
2026-06-06 13:37:53 -04:00
xarmian 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.
2026-06-06 12:11:58 -04:00
xarmian 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.
2026-06-06 11:14:23 -04:00
xarmian 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.
2026-06-06 09:37:27 -04:00
xarmian 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).
2026-06-05 22:31:47 -04:00
xarmian 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.
2026-06-05 21:00:40 -04:00
xarmian 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.
2026-06-05 19:44:16 -04:00
xarmian 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.
2026-06-05 19:36:28 -04:00
xarmian 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.
2026-06-05 19:26:46 -04:00
xarmian 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.
2026-06-05 19:14:45 -04:00
xarmian 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.
2026-06-05 18:57:07 -04:00
xarmian 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.
2026-06-05 18:45:21 -04:00
xarmian 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.
2026-06-05 18:22:53 -04:00
xarmian 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.
2026-06-02 16:57:18 -04:00
xarmian 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.
2026-06-02 08:41:54 -04:00
xarmian 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.
2026-06-01 21:25:42 -04:00
xarmian 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.
2026-05-31 11:54:38 -04:00
xarmian 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).
2026-05-31 11:39:02 -04:00
xarmian 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.
2026-05-31 07:53:54 -04:00
xarmian 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.
2026-05-31 07:51:20 -04:00
xarmian 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.
2026-05-31 01:51:32 -04:00
xarmian 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.
2026-05-31 01:41:06 -04:00
xarmian 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.
2026-05-31 01:31:46 -04:00
xarmian 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.
2026-05-31 01:15:38 -04:00
xarmian 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.
2026-05-31 01:04:50 -04:00
xarmian 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.
2026-05-31 00:59:01 -04:00
xarmian 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.
2026-05-30 23:38:23 -04:00
xarmian 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.
2026-05-30 22:47:00 -04:00