Commit Graph

383 Commits

Author SHA1 Message Date
xarmian 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.
2026-05-30 18:41:55 -04:00
xarmian 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.
2026-05-30 15:30:54 -04:00
xarmian 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.
2026-05-30 12:57:48 -04:00
xarmian 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 `![](pad-attachment:…)` 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).
2026-05-30 12:49:05 -04:00
xarmian 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.
2026-05-30 12:38:57 -04:00
xarmian 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.
2026-05-30 11:38:42 -04:00
xarmian e179c595e4 feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650) (#663)
* feat(comments): paste/drop image attachments in comments + inline render (IDEA-1650)

Comment composers were plain textareas with no upload path, and comment
bodies rendered markdown without an attachment resolver — so a
`pad-attachment:UUID` reference would never display. This wires both
halves end to end:

- Compose: paste or drop files into the comment composer (ItemTimeline)
  and the reply box (TimelineCommentCard). A shared helper
  (commentAttachments.ts) splices an "Uploading…" placeholder at the
  caret, uploads concurrently via the existing attachment API, and swaps
  each placeholder for its `pad-attachment:UUID` markdown ref (image
  syntax for image MIMEs, link/chip syntax otherwise — mirrors the
  editor's split). Submit is gated while uploads are in flight.
- Display: ItemTimeline lazily HEAD-probes each referenced UUID (reusing
  the editor's fetchAttachmentMetadata cache), builds a reactive
  resolver, and threads it into renderMarkdown for comments and replies
  so refs render as inline images / file chips.
- Orphan GC: comment uploads leave attachments.item_id NULL (like the
  editor), but the GC reference scan only checked items.content/fields.
  Renamed AttachmentReferencedInItems -> AttachmentReferenced and
  extended it to scan comments.body, so a screenshot referenced only
  from a comment isn't reclaimed after the grace period. Added
  TestOrphanGC_KeepsAttachmentReferencedFromComment.

Follow-up refinement (thumbnails + click-to-expand lightbox) captured as
IDEA-1660.

* fix(comments): escape markdown-significant chars in attachment filenames per Codex review (round 1)

Filenames containing [ ] or backslash could break the generated
![name](pad-attachment:...) markdown. P2 (grant-aware upload auth) is a
pre-existing endpoint-wide gap shared with the rich editor — tracked as
BUG-1661, not fixed here to keep the PR focused.

* fix(comments): preventDefault on dragover for file drops per Codex review (round 2)

Browsers only deliver a file drop to a custom target if its dragover
cancels the default; without it the page navigates to the file. Gated
on isFileDrag so in-textarea text drag-drop is unaffected.
2026-05-30 10:57:31 -04:00
xarmian 5c0d367c12 feat(tags): list/board view toggle on the tag page (TASK-1656) (#661)
* feat(tags): list/board view toggle on the tag page (TASK-1656)

Adds a board (kanban) view to the per-tag page: one lane per collection,
reusing ItemCard (compact). Read-only — no drag/status changes, since moving a
card between collection lanes would mean reclassifying the item.

Implemented as a focused layout on the tag page rather than overloading the
shared DnD/status-grouping BoardView (582 lines), whose semantics don't fit a
read-only cross-collection view. Reuses the existing collection-grouped
`groupedItems` derived; view mode persists per workspace via localStorage.

Parent: PLAN-1652.

* fix(tags): guard localStorage access on the tag view toggle per Codex review (round 1)

localStorage get/set can throw when storage is disabled or blocked; wrap both
so the page loads and the toggle works (in-session) regardless.
2026-05-30 02:55:06 -04:00
xarmian 1e3b3254d7 feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655) (#660)
* feat(tags): render tag chips on cards, list rows, and the item header (TASK-1655)

- lib/types: shared parseTags() — defensive JSON-array parse + case-insensitive
  dedupe; reused by ItemCard and the detail page (which drops its inline dupe).
- ItemCard (shared by BoardView + ListView): a clickable tag-chip row. The card
  is an <a>, so chips are buttons that goto the tag page with stopPropagation —
  same pattern as the existing star/PR/status controls — avoiding nested anchors.
- Item detail header: tag chips as real <a> links to the tag page.

Chips link to /[ws]/tags/[tag]; those pages arrive in TASK-1657.

Parent: PLAN-1652.

* feat(tags): dedicated tag pages (index + per-tag aggregated view) + nav (TASK-1657)

Folded into the chip-display PR because the chip links require the routes to
exist — without them /[ws]/tags/[tag] is swallowed by the generic
[collection]/[slug] route and hits the item error path (Codex PR #660 round 1).

- /[ws]/tags — index: tag cloud with per-tag item counts (GET /tags), each
  linking to its tag page.
- /[ws]/tags/[tag] — aggregated view: cross-collection items for the tag,
  grouped by collection (the shared axis across heterogeneous status enums),
  modeled on the starred page. A static `tags` segment takes routing
  precedence over [collection], so the collision is resolved.
- Sidebar: a Tags nav entry; `tags` added to the reserved-slug guard so the
  route isn't mistaken for a collection.

Parent: PLAN-1652.

* fix(tags): reserve 'tags' collection slug + tighten Tags nav active-match per Codex review (round 2)

- backend: add 'tags' to reservedCollectionSlugs so a collection can't be
  created with a slug that the /tags routes would shadow.
- Sidebar: isTagsPage now matches exactly /tags or the /tags/ boundary, not
  any /tags* prefix (e.g. a 'tags-collection' path no longer marks it active).

* fix(tags): don't drop item-granted rows whose collection isn't listable per Codex review (round 3)

A restricted member can see an item via item-level grant without being able to
list its collection, so getCollection() missed and the row was dropped while
still counted (count != rendered). Synthesize a minimal collection from the
item's embedded collection_* metadata so those rows render (empty schema =
ItemCard omits status/priority).
2026-05-30 02:37:23 -04:00
xarmian feb068a91f feat(tags): tag chip editor on the item detail page (TASK-1654) (#659)
* feat(tags): tag chip editor on the item detail page (TASK-1654)

Tags live on item.tags (a JSON-array string), not the collection schema, so
this adds a TagInput sibling to FieldEditor rather than a field type.

- TagInput.svelte: chip editor — Enter/comma to add, Backspace/× to remove,
  case-insensitive dedupe (stored as typed), autocomplete dropdown sourced
  from the workspace tag set; readonly mode renders plain chips.
- Item detail page: derive `tags` from item.tags (defensive parse),
  load `tagSuggestions` via a workspace-keyed $effect kept separate from the
  item-load path (Svelte 5 effect-splitting convention), and updateTags()
  mirrors updateField() — optimistic with revert-on-failure, PATCHing `tags`.
  The Tags row renders between the schema fields and the Assignment section.

api.items.update already accepted `tags` via ItemUpdate, so no client change
was needed there.

Parent: PLAN-1652.

* fix(tags): guard overlapping tag saves with a sequence counter per Codex review (round 1)

Rapid chip edits can issue overlapping PATCHes; a late-resolving older
request could clobber the newer tag set with stale data or an errant revert.
Only the latest save (by monotonic seq) applies its result or reverts.

* fix(tags): drop stale tag-suggestion results across workspace navigation per Codex review (round 2)

loadTagSuggestions now only assigns when the in-flight workspace still
matches the current one, so a slower old-workspace /tags response can't
overwrite the new workspace's autocomplete.

* fix(tags): dedupe tags + key chips by index per Codex review (round 3)

An item can carry duplicate tags (e.g. ["ux","ux"]) since the write path
doesn't enforce per-item uniqueness, which would collide value-based Svelte
keys. Key chips by index, and dedupe case-insensitively at the source so the
cleaned set persists on the next save.

* fix(tags): gate tag-save completion UI on item freshness per Codex review (round 4)

If the user navigates to another item while a tag save is in flight (no
further edit, so the seq guard doesn't trip), skip showSaved()/toast/refresh
so completion UI can't fire on an unrelated page.

* fix(tags): serialize+coalesce tag saves, revert to last confirmed per Codex review (round 5)

Replace the concurrent-PATCH-with-seq-guard approach with a single
in-flight, coalescing saver scoped per item. Eliminates the overlap class
structurally: no stale completion clobbers a newer set, and `confirmed`
tracks the last server-acknowledged tags so a failed save reverts to server
truth rather than an optimistic unconfirmed value. Subsumes the round-1 race
guard and round-4 navigation gate.

* fix(tags): key tag savers by item id to prevent cross-navigation concurrency per Codex review (round 6)

A single saver slot let navigating away from an item mid-save and back spawn
a second concurrent saver for it. Hold savers in a Map keyed by item id so
edits coalesce into the existing in-flight saver; evict on drain.

* fix(tags): reapply in-flight desired tags after item reload per Codex review (round 7)

Navigating away and back mid-save reloaded stale server tags; a follow-up
edit computed from that stale set could drop the in-flight edit. The saver
now tracks the latest desired set and loadData reapplies it when a save is
still in flight for the reloaded item.

* fix(tags): keep save indicator active across reload so refresh guards hold per Codex review (round 8)

loadData reset saveStatus to idle while a tag PATCH was still in flight,
letting SSE/sync snapshot adoption bypass the saveStatus==='saving' guard and
land stale tags. Restore 'saving' when reapplying an in-flight saver so the
existing refresh guards keep skipping until the save drains.

* fix(tags): overlay in-flight tags at every server-snapshot assignment per Codex review (round 9)

The saveStatus guard is racy (checked before the refresh handlers' own await,
not after), so a concurrent snapshot could still drop optimistic tags. Extract
withInflightTags() and route ALL item = <server snapshot> sites through it
(realtime SSE/sync, initial load, content-save echoes, title/field/assignment/
role update echoes, post-action refresh, version restore). Overlaying the
saver's desired set at assignment time is race-free regardless of the guard.

* fix(tags): overlay tags on field-save + forced-retry echoes per Codex review (round 10)

updateField's success echo (item = fresh) and the forced open-children retry
(item = forced) were the last two un-overlaid server-snapshot assignments;
route both through withInflightTags so a concurrent tag save isn't clobbered.

* fix(tags): preserve unsaved content when reconciling tag-save echo per Codex review (round 11)

flushTagSaver adopted the full tag PATCH response (item = fresh), which
carries server content and could clobber unsaved editor edits. Route it
through adoptServerItem so local content is preserved (non-collab) like the
other snapshot adoption sites.
2026-05-30 01:58:17 -04:00
xarmian 1b1068537c feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653) (#658)
* feat(tags): workspace tag enumeration endpoint + cross-collection filter (TASK-1653)

Foundation for the tags feature (PLAN-1652 / IDEA-1649). The write path and
per-collection ?tag= filter already existed; this adds tag enumeration and a
verified cross-collection read so a single tag can group items of any type.

- store: dialect.JSONArrayElements unnests a JSON text-array column
  (json_each on SQLite, jsonb_array_elements_text on Postgres);
  Store.ListWorkspaceTags returns distinct tags + item counts, ordered by
  count desc then tag asc, with the same collection/item ACL filters as
  ListItems so counts never leak hidden items.
- server: GET /workspaces/{ws}/tags (handleListTags), respecting collection
  visibility + guest item grants.
- models: TagCount{tag,count}.
- cli: client.ListTags + `pad tag list`.
- web: api.tags.list + TagCount type (items.list already forwards `tag`).
- tests: store-level (cross-collection aggregation, collection scoping,
  non-nil-empty = empty, archived excluded) and handler-level (a Task + an
  Idea sharing one tag; GET /tags counts + ordering).

Parent: PLAN-1652.

* fix(tags): count distinct items per tag, not tag occurrences per Codex review (round 1)

COUNT(DISTINCT i.id) so an item with duplicate tags (e.g. ["ux","ux"]) is
counted once — the write path doesn't enforce per-item tag uniqueness.
Adds a regression test.
2026-05-29 23:43:02 -04:00
xarmian 9870f364ac feat(insights): "What shipped" card with linked item IDs on the dashboard (#657)
Mirror the print report's completed-items list on the live Insights
dashboard, grouped by collection. Each item ID is a link to the item
(/{user}/{ws}/{collection}/{ref}) — the route resolves PREFIX-NUMBER refs
via ResolveItem, so no slug lookup is needed. Fetches the report with
include_items=true; the card is toggleable via Customize and persists like
the other cards (new "completed_items" id, defaults visible).
2026-05-29 22:19:01 -04:00
xarmian a6667152cc fix(insights): professional print formatting for the exec report (TASK-1647) (#656)
* fix(insights): professional print formatting for the exec report (TASK-1647)

Polish the /insights/print @media print block (screen view unchanged):
- Print typography: 10pt base so em sizes cascade down; capped headings
  (title 16pt, section 12pt, stat values 14pt, labels 8–9pt).
- Charts: cap canvas height to 150px in print (overrides the inline 200–240px);
  smaller legend.
- Page breaks: break-inside:avoid on status tables + rows + stat cards + chart
  canvases; section headings kept with their content (break-after:avoid).
- Density: tighter report/section/stat/list gaps for a compact report.

Parent: PLAN-1628 (IDEA-1627).

* redesign(insights): charts-up-front dashboard layout + fix LayerCake print overlap (TASK-1647)

- Move the three charts to a compact 2-up grid right after the headline
  stats (charts up front, half-width, not full-bleed).
- Fix the chart-overlap bug: charts were sized via an inline screen height
  that LayerCake measured and cached, then an @media-print rule shrank the
  box afterwards, leaving the SVG drawn at the old range and spilling onto
  the next panel. Now pass small heights (130-150px) identical for screen +
  print so the measurement matches; add overflow:hidden as a clip-safety.
- Cycle time becomes a full-width band: metrics rail + short chart.
- What shipped flows into two columns (dense, fills page width) and is no
  longer kept whole — .shipped-group/.block/.status-table dropped from the
  break-inside:avoid set so a tall section no longer jumps to a fresh page
  and leaves the big blank gap on page 1. Only small atoms (tr, shipped
  line) stay unsplit.
- Compact table cell padding + repeat thead across page breaks.

* fix(insights): stop print charts clipping + shorten throughput X labels (TASK-1647)

- Charts were measured at the 960px app width on screen (~450px per 2-col
  panel) then printed into ~360px columns, so LayerCake's cached width drew
  the SVG too wide and overflow:hidden clipped the right edge (only ~3 of 4
  collection bars showed). Constrain the print page's on-screen report to a
  paper column (720px) so the measured width already fits the printed sheet —
  WYSIWYG, no clipping, all bars visible.
- Throughput X-axis used full ISO dates (2026-05-23) that overlap once several
  buckets share a narrow chart; render compact M/D (or 'M/D Hh' for hourly)
  labels via fmtBucket. maxTicks=8 already thins longer windows.
2026-05-29 22:18:58 -04:00
xarmian 0d46842cf7 feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642) (#652)
* feat(insights): print-optimized exec report view (Save as PDF) (TASK-1642)

Add /[username]/[workspace]/insights/print — a clean, shareable report of
"what's been done this period" for the active selection, exported via browser
print (no server-side PDF dependency). A "Print report" link on the Insights
page passes the current window/offset/collections as query params.

The report renders: header (workspace, period label honoring offset,
generated-on), headline stats (completed/created/net flow/median cycle-time),
"what shipped" (completed_items grouped by collection, ref + title, +N more on
overflow), all charts (throughput, completed-by-collection, cycle-time) as SVG,
and a compact status table. A layout reset (+layout@) drops the workspace
chrome; @media print + :global hides the root shell/sidebar + the Print button,
with sensible page breaks. Loading/error/empty states handled.

Parent: PLAN-1628.

* fix(insights): hide mobile topbar in print report CSS per Codex review (round 1)

The print @media rules only hid chrome inside .app-layout, but the mobile
TopBar (<header class="topbar topbar-mobile">) renders as a sibling before
.app-layout, so a Save-as-PDF from a mobile viewport included the app bar.
Hide :global(.topbar-mobile) in print too.
2026-05-29 18:06:53 -04:00
xarmian b68164a714 feat(report): opt-in 'what shipped' completed-items list (TASK-1641) (#651)
* feat(report): opt-in 'what shipped' completed-items list (TASK-1641)

Add ?include_items=true → completed_items[{ref,title,collection,completed_at}]
on the report: items that reached a positive terminal in the window, deduped
by item (newest completion first), capped at 500 with
completed_items_overflow_count. Same positive-terminal source as
totals.completed (joins live items, deleted_at IS NULL), so the list reconciles
with the count. Opt-in so the interactive dashboard stays count-only; the
print/export report (TASK-1642) requests it.

Web ReportData gains completed_items + the api.report.get includeItems flag.
Parent: PLAN-1628.

* fix(report): scope completed-items list to the item's current visible collection per Codex review (round 1)

The list scoped transitions by st.collection_id (visible at completion) but
returned the item's CURRENT title/ref/collection — so an item completed while
visible then moved to a hidden collection could leak its hidden collection
slug/prefix + current title to a restricted caller. Require i.collection_id to
be in the resolved (scoped) collection set on both the count and list queries.
Adds a move-to-hidden-collection visibility test.
2026-05-29 17:47:25 -04:00
xarmian 4368c576c5 feat(insights): reconstruct historical WIP + status snapshot for past periods (TASK-1640) (#650)
* feat(insights): reconstruct historical WIP + status snapshot for past periods (TASK-1640)

For a past period (offset>0), reconstruct the status-distribution and WIP
snapshot as-of the window end from status_transitions, instead of hiding them.
An item's status as-of-T = the to_status of its latest transition on the
collection's done field with created_at <= T (NOT EXISTS "no later transition",
dual-dialect, no window functions); only items that existed at T (created_at
<= T) and weren't deleted by T are counted. WIP age is measured against T.

offset 0 keeps the live-from-items path (ground truth for now). Frontend drops
the interim hide so the cards show in the past, with a note that they're
reconstructed (older periods approximate — the backfill parsed the
debounce-coalesced activity log).

Parent: PLAN-1628.

* fix(insights): items-first historical snapshot + period-aware WIP subtitle per Codex review (round 1)

1. reportSnapshotAsOf started from status_transitions, so an item that existed
   at T but had no done-field transition (e.g. created with fields={}) was
   dropped — undercounting historical WIP vs the live path, which treats a
   missing done value as open. Start from items and correlated-subquery the
   latest transition <= T (NULL → empty/open). Adds a no-transition-open test.
2. WIP card subtitle "Open items right now" → "Open as of this period" when
   viewing a past period (offset>0), matching the reconstructed snapshot.

* fix(store): record done-field CLEARS as transitions so historical snapshot is accurate per Codex review (round 2)

The capture hook only recorded a transition when newStatus != "", so clearing
a done field (e.g. done → unset) wasn't logged — and as-of-T reconstruction
would still show the old terminal value, undercounting historical open work.
input.Fields is the full merged blob (CLI/handler merge before write), so
newStatus == "" genuinely means cleared, not omitted. Drop the `!= ""` guard
in both the update and move hooks so X → "" is recorded. Adds a clear test.

* docs(store): document current-collection attribution as an accepted historical-snapshot limitation (Codex review round 3)

reportSnapshotAsOf attributes items to their CURRENT collection for past
periods (not the collection at t). Reconstructing historical collection
membership needs move-history replay (status-preserving moves record no
transition) — disproportionate, and consistent with the same current-collection
attribution the backfill + completed-by-collection already use. Documented as a
deliberate best-effort limitation rather than built; items rarely move.

* docs(store): document same-second transition-ordering limitation + file follow-up (Codex review round 4)

reportSnapshotAsOf's "latest transition <= t" is nondeterministic for 2+
same-second status hops on one item (second-precision created_at + random
UUID ids). Rare; documented as a known limitation and tracked as a follow-up
task (monotonic ordering column / sub-second timestamps).
2026-05-29 17:30:23 -04:00
xarmian 0d0c660565 feat(insights): navigate to past periods (offset + prev/next) (TASK-1639) (#649)
Add an `offset` to the report (periods back; 0 = current, clamped >= 0):
window becomes [now - (offset+1)*lookback, now - offset*lookback]. Throughput
and cycle-time shift automatically; response echoes `offset` + shifted range.

Backend: ReportOptions.Offset + ReportData.offset; handler parses ?offset=.
Web: api.report.get passes offset; ReportData.offset typed.
Insights page: ◀ Previous / Next ▶ controls (Next disabled at offset 0) + a
period label; offset is session-only (not persisted to the layout); resets on
window or workspace change. Interim: WIP + status-distribution are hidden when
viewing a past period (they're as-of-now) with a note — TASK-1640 reconstructs
them historically.

Parent: PLAN-1628.
2026-05-29 16:59:37 -04:00
xarmian cd8ac9b618 feat(charts): per-category hover tooltips + a11y titles (TASK-1638) (#648)
* feat(charts): per-category hover tooltips + a11y titles (TASK-1638)

Hovering a bar chart shows exact numbers. Shared BarChart gains a per-category
tooltip (hover a bucket → label + all series with color swatches), so it lands
on throughput, aging, and completed-by-collection at once.

- Bars layer: invisible full-height hit-rect per band reports the hovered
  index + band center; lifted to BarChart which renders an edge-clamped,
  absolutely-positioned HTML tooltip. Grouped inner-band layout unchanged.
- A11y: native <title> per visible rect (and a band-summary title on the
  hit-rect); chart keeps role=img + aria-label.
- Sparkline: concise native <title> (latest / min–max).

Parent: PLAN-1628.

* fix(charts): cap tooltip width to the chart so it can't overflow per Codex review (round 1)

min-width:max-content prevented the tooltip from shrinking, so long
(user-controlled) collection names overflowed the canvas/viewport on narrow
screens — the center clamp only repositioned. Cap max-width to the measured
canvas width (box-sizing:border-box), let the header wrap (overflow-wrap),
ellipsis-truncate long series labels (min-width:0), and keep the value column
unshrunk (flex-shrink:0).
2026-05-29 16:46:24 -04:00
xarmian eeff78118b feat(insights): per-user layout customization + persistence (TASK-1634) (#645)
* feat(insights): per-user layout customization + persistence (TASK-1634)

Let users personalize the Insights surface, persisted per-user per-workspace:
toggle which metric cards show, and remember the window + collection filter.

Backend:
- migrations 064/043: user_report_layouts (user_id, workspace_id, config JSON,
  PK(user_id,workspace_id), ON DELETE CASCADE) — dual-dialect.
- models.ReportLayout (hidden_cards/default_window/default_collections) +
  ReportCardIDs/ValidReportWindow validation.
- store.GetReportLayout / SaveReportLayout (ON CONFLICT upsert, both dialects).
- GET/PUT /workspaces/{ws}/report/layout — per-user; PUT sanitizes window +
  filters hidden_cards to the known card set. web client + TS type.

Frontend (Insights page):
- loads the saved layout, hydrates window/collections/hidden cards
- a "Customize" panel toggles each card (SvelteSet-backed); each section gated
  on !hiddenCards.has(id); Totals always shown
- debounced auto-save, gated on a per-workspace `hydrated` flag so it never
  saves during load or stomps another workspace's layout on switch

Single config per user (no named/multiple layouts — deliberate v1 scope).
Parent: PLAN-1628.

* fix(insights): save layout only on explicit user changes, not on load per Codex review (round 1)

The auto-save $effect ran once after hydration (loadLayout assigns reactive
state, then flips hydrated=true), firing a PUT /report/layout on mere page
view — which 401s on no-user/legacy-token sessions and bounces the user to
/login. Replace the effect with a scheduleSave() called only from explicit
handlers (toggleCard, selectWindow, toggleCollection, clearCollectionFilter);
hydration never saves. Also capture wsSlug at schedule time and drop the
pending save if the workspace changes mid-debounce, so A's edit can't land
on B.
2026-05-29 15:00:34 -04:00
xarmian 1a728fe51a feat(web): add Insights sidebar nav link (TASK-1636) (#644)
Add an "Insights" nav item to the workspace sidebar directly under Dashboard,
routing to /[username]/[workspace]/insights (the Reports surface, TASK-1633).
Mirrors the existing nav-item pattern with an isInsightsPage active state.
No dashboard widget/CTA (owner directive — keep the dashboard uncluttered).

Parent: PLAN-1628.
2026-05-29 11:03:24 -04:00
xarmian 5afacaf478 feat(web): Insights analytics page (TASK-1633) (#643)
* feat(web): Insights analytics page (TASK-1633)

Add /[username]/[workspace]/insights — the Reports surface consuming
GET /workspaces/{ws}/report via api.report.get + the LayerCake chart library:
- window segmented control (day/week/2wk/month) + collection filter chips,
  both driving refetch via a single $effect
- totals (created/completed/net flow), throughput BarChart (created vs
  completed per bucket), cycle-time (median/p90 + per-collection), WIP
  (open count, median age, aging-band BarChart, per-collection), completed-
  by-collection BarChart, status-distribution bar rows
- loading/error/empty states; responsive grid; titleStore "Insights"

Route is /insights (owner directive); sidebar link is TASK-1636. Card
toggling + saved layouts are TASK-1634. Svelte 5 runes; MCP-validated;
npm run check 0 errors. Parent: PLAN-1628.

* fix(web): reset insights collection filter on workspace change per Codex review (round 1)

SvelteKit reuses the route component across workspace param changes, so a
collection filter selected in workspace A leaked into B — sending A's slugs to
B's /report, which the server scopes to an empty set (no match) → empty report
for a non-empty workspace. Track the previous wsSlug in a plain (non-reactive)
var and clear selectedCollections on an actual workspace change before
snapshotting, so the new workspace starts unfiltered. Loop-safe.

* fix(web): guard insights report fetch against stale/out-of-order responses per Codex review (round 2)

loadReport left the previous report visible during an in-flight fetch and wrote
responses unconditionally, so switching workspace A→B showed A's data under B's
URL, and a slow older request could overwrite a newer selection. Add a plain
request-sequence counter: capture seq at fetch start, commit report/error only
when seq is still the latest, and only the latest request clears `loading`.
Also clear report/collections on an actual workspace change so A's data doesn't
linger under B while B loads.

* fix: reserve 'insights' collection slug to avoid route shadowing per Codex review (round 3)

The static /[username]/[workspace]/insights route shadows the dynamic
/{collection} route, so an 'insights'-slugged collection would be unreachable.
Add 'insights' to reservedCollectionSlugs (server, blocks creation) and to the
Sidebar's reserved-slug filter, matching the existing activity/starred/library/
ref precedent (which likewise reserve UI routes without migrating pre-existing
data — an 'insights' collection on this new feature is not expected).
2026-05-29 10:56:21 -04:00
xarmian d9fab3ea02 feat(report): cycle-time + WIP/aging metrics (TASK-1631) (#642)
* feat(report): cycle-time + WIP/aging metrics (TASK-1631)

Extend GET /workspaces/{ws}/report with two metric blocks:
- cycle_time: created→positive-terminal duration for completions in the
  window — overall median + p90 + per-collection medians.
- wip: point-in-time open items (done field NOT a terminal value), open count,
  median age, fixed aging bands (<1d/1-7d/7-30d/>30d), per-collection median age.

Medians/percentiles computed in Go from raw durations (dual-dialect: neither
SQLite nor Postgres has a portable percentile). Completed/WIP queries join live
items (deleted_at IS NULL), consistent with the rest of the report.

Wires through web ReportData TS types + `pad project report` rendering.
Tests: cycle-time median (backdated 48h), WIP open-count + aging bands,
percentile helper. Parent: PLAN-1628.

* fix(report): well-formed cycle_time/wip arrays on empty-scope path per Codex review (round 1)

The no-visible-collections early return left cycle_time.by_collection,
wip.aging_buckets, and wip.by_collection as nil → marshaled null, violating
the TS array contract for guests/restricted callers. Initialize those nested
slices in the ReportData literal so every path (including the early return) is
well-formed. Adds a JSON-shape regression test for the empty-scope case.
2026-05-29 09:26:39 -04:00
xarmian 0c8c066dd6 feat(web): LayerCake chart component library for Reports (TASK-1632) (#639)
Add a reusable charting library under web/src/lib/components/charts/ to power
the upcoming Reports surface (TASK-1633):
- BarChart / LineChart / Sparkline (public) + Bars/Lines/AxisX/AxisY layers
- theme.ts: CSS-var palette with hex fallbacks + typed LayerCake context
- Svelte 5 runes; role=img + aria-label; "No data" empty states

Library only (not wired into a page yet). Deps: layercake@10.0.2 +
d3-scale@4.0.2 (+@types/d3-scale); npm override lets layercake accept the
repo's TypeScript 6. ~8-12KB gzipped added when the Reports bundle imports
them; Sparkline is dependency-free SVG.

Parent: PLAN-1628.
2026-05-29 09:00:16 -04:00
xarmian a1d09c90df feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630) (#638)
* feat(report): windowed project report endpoint + DateBucket dialect (TASK-1630)

GET /workspaces/{ws}/report?window=week&collections=tasks,bugs returns a
time-bucketed report: created-vs-completed throughput, net flow,
completed-by-collection, and a current status-distribution snapshot.

- Dialect.DateBucket(column, granularity) — day/hour bucketing via fixed-width
  substring on the UTC RFC3339 TEXT (identical + exact on SQLite + Postgres;
  avoids SQLite 'Z'-parsing fragility). Routes all report date math through it.
- store.GetReport: resolves per-collection done field + positive terminals
  (terminal options minus rejected/cancelled/etc.), counts completions from
  status_transitions and created from items.created_at, zero-fills buckets.
- HTTP handler + route; web ReportData type + api.report.get client.
- Tests: throughput/totals, negative-terminal exclusion, status distribution,
  collection filter, non-status done-field, out-of-window exclusion, hourly
  day-window, DateBucket per granularity. Dual-dialect via testStore.

Fixes the response contract that TASK-1632/1633/1635 consume (noted on them).
Parent: PLAN-1628.

* fix(report): scope report to caller's visible collections per Codex review (round 1)

The endpoint sits under RequireWorkspaceAccess (members, restricted members,
guests), but GetReport resolved ALL workspace collections — letting a caller
with access to one collection infer hidden collections' slugs, created/
completed counts, and status distribution. Mirror the dashboard: the handler
computes visibleCollectionIDs() and GetReport restricts to that set
(ScopeToVisible). Empty visible set → empty report. Aggregate reports are a
full-collection-visibility feature; item-level grants aren't surfaced in
workspace-wide counts.

* fix(report): correct visibility scoping for all-access + item-grant callers per Codex review (round 2)

Round 1's scoping had two bugs in how it read visibleCollectionIDs:
1. nil means "all-access" (admin / collection_access=all), but the handler
   treated nil as an empty visible set → all-access users got an EMPTY report.
   Now nil → ScopeToVisible stays false (full workspace report).
2. For guests, visibleCollectionIDs includes collections visible only via
   item-level grants; passing those to the aggregate report leaked the whole
   collection's counts. Now mirror the dashboard: when item-level grants are
   present, scope to fullCollIDs (full-access collections only).

Adds report handler tests (owner full report + default window) alongside the
store-level scoping test.

* fix(report): bearer-aware admin visibility scoping per Codex review (round 3)

visibleCollectionIDs grants ANY platform admin an unrestricted (nil) view, but
RequireWorkspaceAccess suppresses the platform-admin bypass for bearer auth and
falls through to membership (BUG-1616/1617). So a bearer admin (PAT/CLI/OAuth)
who is only a restricted workspace member could read the full workspace report.

Extract reportVisibleCollections(): gate the admin bypass on cookie auth; for
everyone else resolve actual member/guest visibility, and when item-level
grants exist scope to the full-access collection set only. Adds a cookie-vs-
bearer scoping test (cookie admin unrestricted, bearer restricted-member scoped
to the granted collection, end-to-end through GetReport).

* fix(report): exclude soft-deleted items from completion counts per Codex review (round 4)

status_transitions rows survive a soft delete (only a HARD delete cascades
them), so a completed-then-soft-deleted item still counted toward completed /
completed_by_collection while created and status_distribution (which filter
deleted_at IS NULL) excluded it — inconsistent totals. Join live items in both
completed queries. Adds a regression test.
2026-05-29 08:24:05 -04:00
xarmian 225fb4a53f Wire upgrade CTAs with Stripe-ready billing flow (TASK-800) (#629)
* feat(billing): add billing_available session flag gated on PAD_BILLING_AVAILABLE (TASK-800)

Add Server.billingAvailable field set by SetBillingAvailable(), called from
cmd/pad/main.go when PAD_BILLING_AVAILABLE=true|1. Expose the flag as
billing_available in both the setup-state and authenticated session payloads
(value: cloudMode && billingAvailable) so the web UI can gate Stripe CTAs
without a code change at deploy time. False by default.

* feat(billing): wire upgrade CTAs, checkout POST flow, plan section, clickable limit toasts (TASK-800)

Frontend prep work gated on authStore.billingAvailable (from billing_available
session field). When false, upgrade buttons remain hidden and the "coming soon"
note stays in place — flip PAD_BILLING_AVAILABLE=true at deploy time.

Changes:
- client.ts: add billing_available to AuthSession; add api.billing.createCheckoutSession()
  (POST /billing/checkout → parse {url} → caller does window.location.href)
- auth.svelte.ts: billingAvailable getter
- console/billing: replace STRIPE_AVAILABLE=false with $derived(authStore.billingAvailable);
  fix GET→POST on upgrade buttons; add ?checkout=cancelled banner; add cancelled style
- console/settings: new cloud-mode-gated "Plan" section with current plan + upgrade/manage link
- All 11 limit-hit sites: replace plain-text '/console/billing' appendage with
  toastStore.show(msg, 'error', 6000, '/console/billing') so the toast is clickable

* docs(billing): document pad-cloud CSRF and error-envelope contract divergences in createCheckoutSession (TASK-800)
2026-05-25 13:18:43 -04:00
xarmian 342679a364 Standardize plan-limit error envelope across HTTP/MCP/CLI/UI (TASK-788) (#628)
* fix: limit-hit responses were actively broken — garbled toasts, no upgrade signal

The limit enforcement responses (plan_limit_exceeded on 403) used a flat
body shape {"error": "plan_limit_exceeded", ...} that is incompatible with
every consumer: the frontend PadApiError parser, the CLI parseError path,
and the MCP classifyHTTPStatusKind all expect {"error": {"code": ...,
"message": ...}}. As a result, hitting any of the 5 plan limits (items,
members, workspaces, api_tokens, webhooks) produced garbled toasts with
undefined message text and zero upgrade signal.

Fix:
- writePlanLimitError now emits the standard nested error envelope with a
  human-readable message sentence and limit details in error.details.
- CLI parseError now correctly surfacing the message (net positive, no
  code change needed).
- MCP classifyHTTPStatusKind: adds ErrPlanLimitExceeded to the taxonomy
  and the allowedStructuredErrorCodes whitelist so 403 plan-limit errors
  pass through with code + details rather than collapsing to
  ErrPermissionDenied (TASK-788).
- Frontend: exports isPlanLimitError() type-guard and planLimitMessage()
  formatter from client.ts; all 4 limit-hit write call sites (item create,
  member invite, workspace create, token create) now branch on the code and
  show an upgrade-signal message pointing at /console/billing.
- Test: updates handlers_workspace_cap_test.go to the new body shape; adds
  TestPlanLimitError_ResponseShape covering members_per_workspace limit hit.

TASK-788

* fix(R1): cover MCP stdio path, 5 more item-create sites, polish message wording

Finding A — MCP stdio transport was missing plan-limit coverage:
- cli/client.go: add PlanLimitDetails struct, AsPlanLimit() helper, and
  WritePlanLimitError() that emits the pad-structured-error/v1 marker so
  the MCP stdio classifier can lift code + details instead of falling
  through to ErrServerError.
- cmd/pad/main.go: wire the WritePlanLimitError branch into all three
  CreateItem call sites (item create, convention activate, playbook activate).
- internal/mcp: add TestClassifyHTTPStatus_PlanLimitPreservesCodeAndDetails,
  TestClassifyHTTPStatus_Generic403FallsToPermissionDenied,
  TestClassifyExecError_PlanLimitMarkerLiftsStructuredPayload, and
  TestClassifyExecError_PlanLimitWithoutMarkerFallsThrough.

Note: extractUpstreamErrorEnvelope already parses details (json.RawMessage
field) — the codex concern about it being silently empty was a false alarm;
no fix needed there.

Finding B — 5 more item-create entry points were unguarded:
- EditorBubbleMenu.svelte (inline wiki-link capture)
- Sidebar.svelte (quick-add)
- roles/+page.svelte (board new-item, was console.error only; adds toastStore)
- conventions/+page.svelte
- playbooks/+page.svelte (both create and duplicate paths)

B1/B2 polish — server message is now statement-of-fact only, no doubled
upgrade verb. planLimitMessage() drops "Upgrade to Pro to add more." (each
surface appends its own CTA). limitStr uses hyphenated adjective form
"3-member" / "10-item" (compound modifier before "limit").

TASK-788

* feat(task-788): extend MCP-stdio plan-limit coverage to workspace, invite, webhook

Wire WritePlanLimitError into three additional CLI command error paths so
the MCP stdio classifier surfaces ErrPlanLimitExceeded with details instead
of falling through to ErrServerError:

- workspaceCreateCmd: check before fmt.Errorf wraps the APIError
- inviteCmd: check before returning the raw error
- webhooksCreateCmd: check before returning the raw error

Add TestClassifyExecError_PlanLimitWorkspaceCreate to exercise the full
workspace-create stdio round-trip through classifyExecError, asserting
ErrPlanLimitExceeded code, feature="workspaces", limit, and upgrade_url.

Token create intentionally left bare (agents don't drive token creation).
2026-05-25 11:46:41 -04:00
xarmian c1c9f2ef97 fix(share): guard nil grants/share-link slices to prevent ShareDialog TypeError (BUG-1598) (#624)
The Share dialog threw "Cannot read properties of null (reading 'length')"
on any item/collection with zero grants. Store.ListCollectionGrants /
ListItemGrants return a nil slice when there are no rows, which writeJSON
encoded as JSON null; the client then hit grants.length on null.

Server: nil-to-[]{} guard in handleListCollectionGrants and
handleListItemGrants, mirroring the existing pattern in
handlers_share_links.go.

Client: defensive ?? [] in ShareDialog.svelte's loadGrants and
loadShareLinks so a null body can't reach .length checks.
2026-05-24 16:12:38 -04:00
xarmian 35ac7552eb feat(backlinks): UI panel + mention badge + CLI + MCP (Phase 3) (#623)
* feat(backlinks): UI panel + mention badge + CLI + MCP (Phase 3)

Phase 3 of PLAN-1593 (TASK-1596). Surfaces the backlinks index
shipped in Phases 1/2a/2b across every place users live: web UI,
CLI, MCP.

What changed

Web UI
- New BacklinksPanel.svelte at web/src/lib/components/. Fetches via
  api.items.backlinks (new method) and renders inbound `[[...]]`
  references grouped by source collection. Per-row: collection icon,
  ref + title, snippet, relative timestamp, optional `(displayed as)`
  override, faint workspace badge on cross-ws rows. Pagination via
  "Show older" when the page is full. Collapses entirely when the
  count is zero — no header, no whitespace, no empty surface for
  items with no inbound links.
- Mention badge ("📎 N") in the item-page action bar next to the
  Timeline button. Hidden when N=0; smooth-scrolls to the panel.
  Wired via onCountChange callback so badge + panel stay in sync.
- New Backlink TypeScript type at web/src/lib/types/index.ts mirroring
  internal/models/backlink.go; new api.items.backlinks(ws, slug, opts)
  client method.

CLI
- `pad item show <ref>` enriched with inline top-5 "Mentioned in"
  section in TTY mode (skipped when empty), and a backlinks_top
  array in JSON output. Hint at the dedicated `pad item backlinks`
  command when the inline list hits the 5-row cap.

MCP
- New `pad_item.action: backlinks` — passes through to
  `pad item backlinks <ref>` with optional `limit` (default 50,
  max 300) + `offset` params. Bumps ToolSurfaceVersion 0.5 → 0.6
  with a backwards-compatible additive note in version.go. Updated
  the catalog_readonly_test fixtures so the cmdhelp drift check
  passes.

Test plan
- [x] go build ./... + go test ./internal/mcp/ green
- [x] make check (lint + Go + web) green
- [x] make install + restart
- [x] pad item show TASK-1596 shows --- Mentioned in --- inline
- [x] pad item show TASK-1596 --format json includes backlinks_top
- [x] svelte-check 0 errors
- [ ] /codex review --loop → CLEAN

Out of scope (filed as separate ideas if anyone asks)
- Force-directed graph visualization of the link network
- Broken-links report (target_item_id IS NULL feeds it but it's its
  own feature)

PLAN-1593 / TASK-1596.

* fix(backlinks): unique each-block key for multi-occurrence rows (Codex round 1)

Codex round 1 P1: BacklinksPanel keyed each row by source_item_id,
but the server preserves multiplicity — a source body that mentions
the target three times produces three Backlink rows (Phase 1 design
decision, covered by TestWikiLinks_RepeatedRefStoresMultipleRows).
Duplicate keys in Svelte's #each are rejected at dev time and
silently reuse DOM in prod, so the panel would render only one of
the N rows from a multi-mention source.

Fix: compose a unique key per row via new rowKey(bl, index) helper:
`${source_item_id}|${snippet}|${index}`. The snippet usually
differs across positions (centered on the bracket byte offset);
the index suffix is the unconditional tie-breaker.

PLAN-1593 / TASK-1596.
2026-05-24 15:02:06 -04:00
xarmian ef7eabaddc fix(web): admin modal reactive loop on open (#611)
* fix(web): admin modal reactive loop on open (PLAN-1542 follow-up)

User reported the modal froze the page on open. Root cause was the
UserSettingsForm hydration \$effect added in TASK-1551 — it read prop
fields then reassigned editOverrides to a fresh object and mutated
per-key inside the same effect, while the template's
{#each overrideFields} block subscribed via bind:value to those exact
keys. The reassign+mutate pattern thrashed the bind subscriptions
which scheduled the effect's tracking owner, looping until
effect_update_depth_exceeded.

Fixes (defense in depth):

1. UserSettingsForm hydration \$effect now gates on user.id only
   (a primitive read with no proxy churn) and wraps all writes in
   untrack(). The objects are built locally then assigned ONCE per
   state var — no "reassign then mutate" pattern.

   Side effect: form state isn't nuked by the parent's
   modalUser = { ...modalUser, ...updated } spread after every save.
   The form's own state is authoritative for unsaved edits; re-
   hydration only happens when the modal swaps to a different user.

2. UserModal now mounts only the active tab's body. The earlier
   {#each TABS} + hidden pattern instantiated all four tab
   components on every modal open, amplifying the UserSettingsForm
   loop and firing three lazy-fetch effects in parallel for tabs
   the admin may never click. Tabs are now mount-on-active —
   lazy fetch happens when the admin selects the tab.

3. Lazy-fetch tabs (Overview/Workspaces/Activity) now claim
   fetchedForUserId BEFORE the await, not after. A re-trigger of
   the gating effect (parent passes a new modalUser object with
   the same id) can no longer race in and fire a duplicate
   concurrent fetch. On failure, the claim is cleared so a
   retry-via-reactivation still works.

Verified via svelte-check (0 errors) and npm run build.

* fix: address Codex review on modal reactive-loop fix

1. Keep UserSettingsForm mounted across tab switches (hidden toggle
   rather than {#if}). Mount-on-active for the Settings tab meant an
   admin who tabbed away to Workspaces and back lost unsaved overrides
   and the typed-email disable input. The form has no fetch — its
   hydration $effect is now gated on user.id so the original loop
   stays fixed, and keeping it alive is cheap. Lazy-fetch tabs
   (Overview / Workspaces / Activity) still mount-on-active so their
   network calls fire only when needed.

2. UserOverviewTab now clears fetchedForUserId when EITHER metrics or
   recent-items branch fails (was: only when both failed). A partial
   failure left the failed half stuck on the next activation until
   the admin force-refreshed the page. The successful half gets
   re-fetched on retry, which is cheap.

* fix: Overview tab partial-failure retry is now explicit, not auto

Clearing fetchedForUserId on failure while the tab is still active
re-triggered the gating \$effect immediately — under a persistent
outage that meant a continuous refetch loop and the successful
half's data getting wiped on every iteration. fetchedForUserId now
stays set on failure; the failed branch renders an inline Retry
button that calls loadAll() directly.

* fix: apply the same retry-claim fix to Workspaces + Activity tabs

Codex caught the same auto-retry-loop pattern in UserWorkspacesTab and
UserActivityTab: clearing fetchedForUserId on failure while the tab is
active re-triggers the gating $effect → immediate refetch → repeat.
Both already render explicit Retry buttons (calling loadDetail() /
loadInitial() directly), so the claim can stay set on failure.
2026-05-20 20:17:51 -04:00
xarmian a2c8c67a9c refactor(web): delete inline-expand DOM (TASK-1555) (#610)
* refactor(web): delete inline-expand DOM from admin users page (TASK-1555)

Closes the parallel-UI broken state opened in TASK-1550 and maintained
through T1551-T1554. The modal (UserModal + UserOverviewTab +
UserWorkspacesTab + UserActivityTab + UserSettingsForm) now owns every
admin-user-detail surface; the inline expand goes away.

Removed from +page.svelte:

- The {#if selectedId === user.id} ... edit-row block (220 LOC of DOM)
- selectedId state + class:selected binding on the row
- selectUser() helper (the row click now goes directly to openUserModal)
- All inline-expand-only state: editRole, editPlan, editOverrides,
  extraOverrides, editStorageOverride, storageOverrideError, saving,
  saveMsg, roleConfirm/roleSaving/roleMsg, resetConfirm/resetSaving/
  resetResult/resetError, disableConfirm/disableSaving/disableMsg,
  userWorkspaces, workspacesLoading
- All inline-expand-only handlers: loadUserWorkspaces, selectedUser,
  roleAction, changeRole, resetPassword, toggleDisable, saveUser
- Dead helpers: parsePlanOverrides, parseStorageInput,
  storageOverridePreview (the parse-side now lives in
  UserSettingsForm.svelte where it's actually used)
- ~190 lines of dead CSS (.edit-row, .edit-panel, .edit-field*,
  .overrides-grid, .override-field*, .storage-input*, .storage-preview*,
  .role-row, .role-confirm*, .reset-result, .temp-password*,
  .ws-list/.ws-item/.ws-name/.ws-joined, .badge.owner, .user-row.selected)

Kept (still in use by the table):

- formatStorageBytes — Storage cell renders bytes per row
- relativeTime, writeRecency — Last Write / Last Active columns

File LOC drops from 1482 → 735 (~750 lines removed, 50% smaller).

Part of PLAN-1542. With this merge the plan is complete — backend
foundation (T1543–T1547), table UX (T1548–T1549), and the modal
(T1550–T1555) are all live.

* fix: address Codex review on TASK-1555

Three cleanup misses from the original deletion sweep:

1. Drop unused imports: adminPatch, adminPost — only adminFetch is
   still used by the page (for the list + status counts), the mutating
   calls all live in UserSettingsForm now.

2. Update stale modal comments. The "both UIs coexist" / "shell only"
   notes were accurate during T1550-T1554 but now describe the past;
   replaced with a one-line description of the modal's role.

3. Drop dead CSS rules that survived the inline-expand removal:
   .btn.primary, .btn.primary:hover, .badge.disabled (replaced by
   .badge.status-disabled in T1548), .btn.danger, .btn.danger:hover,
   .btn.primary.danger, .btn.primary.danger:hover. None had any
   remaining users in the markup; svelte-check now reports zero
   admin/+page.svelte warnings.

* fix: drop one more stale comment caught by Codex re-review on TASK-1555
2026-05-20 19:18:13 -04:00
xarmian 88a30e54a4 feat(web): admin modal Activity tab (TASK-1554) (#609)
* feat(web): admin modal Activity tab with pagination (TASK-1554)

New UserActivityTab.svelte renders the full chronological feed from
GET /admin/users/{id}/activity (T1546). Each row shows an action icon
(emoji glyph as a visual scan aid), a human-readable action summary,
the source channel (web / cli / agent / etc.), and a relative
timestamp.

Pagination via the API's next_offset field — "Load more" button at
the foot appends pages until next_offset is null. Page size 20.

Note at the top of the tab explains the scope: this is activities
AUTHORED by this user (item writes, comments, logins/logouts) — admin
actions targeting them as a subject (e.g. another admin disabling them)
live in the activities table under a different user_id and aren't
shown here. Documented as a known follow-up per T1546's PR.

Lazy-fetched on first activation; refetches on user swap; defensive
race-condition guards on each request (snapshot userId; commit only
if user.id is still the same when the response arrives).

Also drops the dead .placeholder CSS rule from UserModal.svelte — all
four tabs now render real content as of this task.

Part of PLAN-1542.

* fix: address Codex review on TASK-1554

1. loadMore failure no longer hides the existing feed. Append errors
   now go to a separate loadMoreError surfaced inline next to the Load
   more button, so an admin scrolling through a long activity history
   keeps everything they've already loaded if the next page fails.
   Button label switches to "Retry" when a load-more error is set.

2. iconFor + describe coverage extended to every action constant in
   internal/models/activity.go that can land in a user-authored feed:
   register, login_failed, password_changed/reset, token_created/
   revoked/rotated, totp_enabled/disabled, oauth_login/oauth_login_
   failed, member_invited/removed, role_changed, settings_changed,
   session_ip_changed, account_deleted. Fallback for unknown actions
   pretty-prints the raw snake_case rather than rendering as-is.

* fix: complete admin-on-user audit action coverage for activity tab

Adds icons + describe entries for the admin-on-user audit events that
land in the user-authored feed when an admin acts on another user
(activities.user_id is the actor, target_user_id is in metadata):
plan_changed, plan_overrides_changed, password_reset_by_admin,
user_disabled, user_enabled.

Closes the remaining Codex finding on PR #609.
2026-05-20 19:07:46 -04:00
xarmian 6765542b13 feat(web): admin modal Overview tab (TASK-1553) (#608)
New UserOverviewTab.svelte — the first thing an admin sees when opening
the modal. Three blocks:

- Vitals header: name + email + role pill + plan pill + account age.
  Disabled badge is on the modal header so we don't duplicate it here.

- Metric tiles (3): Last write (color-coded by writeRecency bucket —
  green <7d, yellow ≤30d, red >30d, gray italic for Never), 7d writes,
  30d collection breadth. Sourced from GET /admin/users/{id}/metrics
  (T1547). Tiles fall back to "—" placeholders on fetch failure rather
  than blocking the rest of the tab.

- Recent items: top 5 from /admin/users/{id}/activity?limit=20 filtered
  client-side to item-write actions (created/updated/archived/restored/
  moved). Comments and admin actions stay in the Activity tab (T1554)
  where they belong.

Sparkline deliberately omitted per the locked-in plan decision; same
for api_requests_7d (pending IDEA-1556 to add per-request tracking).

Lazy fetch on first activation, refetches on user swap, defensive
against in-flight modal swaps (commits only if user.id is unchanged).
Metrics + recent items fire in parallel — slow metrics endpoint
doesn't block the recent list or vice versa.

Part of PLAN-1542.
2026-05-20 18:59:26 -04:00
xarmian 46ca27cf28 feat(web): admin modal Workspaces tab (TASK-1552) (#607)
New UserWorkspacesTab.svelte consumes GET /api/v1/admin/users/{id}/detail
(T1545) and renders the per-workspace breakdown: name + slug (link to
the workspace), role badge, collections count (excludes system —
playbooks + conventions), items open / total, members count, storage
in human-readable units, last-activity relative time.

Lazy load — fetches only on first activation of the Workspaces tab,
not on modal open. Refetches if the modal swaps to a different user
without closing. Defensive against in-flight user swaps (commits the
result only if user.id hasn't changed underneath).

Empty state for users with no workspaces. Retry on error. Server caps
at 50; UI caps visible at 20 with "Show all N" affordance for users
who own a long tail of workspaces.

Wiring: replaces the Workspaces placeholder in UserModal.svelte; passes
`active={activeTab === 'workspaces'}` so the component can gate its fetch.

Part of PLAN-1542.
2026-05-20 18:55:49 -04:00
xarmian 430872314b feat(web): admin modal Settings & overrides tab (TASK-1551) (#606)
* feat(web): admin modal Settings & overrides tab (TASK-1551)

Lifts the inline-expand form into the modal's Settings tab via a new
UserSettingsForm.svelte component. Per the plan, this is a parallel
implementation — the inline expand in +page.svelte is intentionally
left intact so behavior parity can be verified side-by-side before
T1555 deletes it.

Behaviour parity (all matches the existing inline-expand):

- Role selector + promote/demote confirm with separate confirm button
- Password reset (email path → "message" string from server;
  temp-password path → revealed code with a "share via secure channel"
  hint)
- Account disable/enable toggle
- Plan selector + structured plan_overrides grid + storage override
  with shorthand parsing (10 GB / 500 MB / -1 / raw bytes) and the
  live preview chip
- Save button writes plan + plan_overrides in one PATCH; empty
  overrides → empty-string send (preserves the SetUserPlanOverrides
  clear path from the inline-expand)

New for T1551 — typed-email confirmation on the destructive side of
disable (matches the destructive-action confirm pattern called out in
the task body). Disable button stays disabled until the typed input
matches the user's email exactly; Cancel resets the typed value. Enable
side does not require typing (it's reversible).

Wiring:

- UserModal.svelte gains an optional onUserUpdated callback prop. The
  Settings form bubbles every successful save through it; the page
  merges the refetched row into its users[] and the bound modalUser
  so both UIs (modal + inline-expand) see the same state.
- Helpers (parsePlanOverrides, parseStorageInput, formatStorageBytes)
  are duplicated inside UserSettingsForm for this PR — collapsing back
  to one home happens in T1555 when the inline expand goes away. Two
  short-lived copies is cheaper than refactoring a soon-to-be-deleted
  block.

Part of PLAN-1542.

* fix: address Codex review on TASK-1551

1. Snapshot user.id at each async handler entry (changeRole,
   resetPassword, toggleDisable, saveUser) and the email at
   toggleDisable's entry. If the modal closes/swaps to another user
   mid-PATCH, the in-flight request now updates the row it was
   originally targeting rather than whatever user is currently
   displayed. Mirrors the inline-expand handlers' pattern.

2. Typed-email gate stays trim()-tolerant — paste from various
   sources often picks up whitespace. The button title and message
   copy now explicitly say "paste tolerant" so the behavior matches
   what's documented.
2026-05-20 18:52:20 -04:00
xarmian 02491476e2 feat(web): admin user modal shell with empty tabs (TASK-1550) (#605)
* feat(web): admin user modal shell with empty tabs (TASK-1550)

New component: web/src/lib/components/admin/UserModal.svelte. Shell-only
in this PR; tab content arrives across T1551–T1554, and T1555 deletes
the parallel inline-expand block.

Features:

- Backdrop + centered modal at 720px default width (--user-modal-width
  CSS variable so T1552 can widen for the Workspaces table without
  forking layout).
- Four tabs: Overview / Workspaces / Activity / Settings & overrides.
  Each tab renders a placeholder telling future readers which TASK
  fills it. Tabs as <button role="tab"> inside a <div role="tablist">;
  panels carry aria-labelledby + tabindex="0" so screen readers can
  navigate them.
- ESC closes. Click on backdrop closes (stopPropagation guards the
  modal body). Tab key is trapped within the modal so focus can't
  escape into the table behind it.
- Tab key restored from window.location.hash (?...#tab=workspaces) so
  reload preserves the active tab. Tab change writes back via
  history.replaceState (no back-button history pollution).
- Body scroll-locked while open.
- Focus pulled to the close button on open; restored to the originating
  trigger element on close.

Wiring in +page.svelte:

- Row click now opens the modal AND triggers the existing inline-expand
  (selectUser). The "double UI" is the explicit broken state from the
  plan, documented in the row-click comment. T1555 deletes the inline
  expand once T1551-T1554 have lifted everything into the modal.

Part of PLAN-1542.

* fix: address Codex review on TASK-1550

1. Focus trap now excludes hidden tabpanels and other off-screen
   focusables. Previous querySelectorAll picked up every panel's
   tabindex=0 element including the three off-screen ones, so the
   "last" reference pointed at the wrong place and Tab could escape
   the trap on Overview/Workspaces/Activity.

2. writeHashTab + parseHashTab now use URLSearchParams over the
   raw hash string. Previous regex-replace corrupted hashes where
   tab= was first but other params followed (#tab=X&foo=1 became
   &foo=1#tab=Y, moving foo outside the hash entirely). Single
   source of truth for hash param decoding/encoding.

3. Focus capture only runs on the open=false→true transition.
   Previous \$effect re-ran whenever initialTab changed and would
   recapture previousFocus into the modal itself; on close it
   would then "restore" focus to an element inside the modal that
   no longer exists. wasOpen latch makes the open/close behavior
   strictly edge-triggered.

ARIA arrow-key navigation between tabs (ArrowLeft/Right/Home/End)
acknowledged as a follow-up enhancement; tabs are still individually
keyboard-focusable via Tab, which meets the baseline.
2026-05-20 18:43:21 -04:00
xarmian 5990008028 feat(web): admin user list — pagination + sort + filter UI (TASK-1549) (#604)
* feat(web): admin user list — pagination + sort + filter UI (TASK-1549)

Wires up the table-scale UX promised by T1544's API extensions:

Filter bar above the table — Role / Plan (cloud_mode only) / Status /
Active within / Has workspaces. A "Clear filters" affordance appears
when anything is set. Status filter is mostly server-mapped (disabled →
disabled=true; no-workspace → has_workspaces=false); "active" and
"inactive" don't have a direct API param so they apply as a client-side
narrow over the page (applyClientStatusFilter — documented as a known
limitation in the pager footer with a "(client-filtered)" caveat).

Sortable column headers — Workspaces, Email, Storage, Last Write,
Last Active, Created. Click to sort; second click reverses direction.
Default direction is "asc" for email, "desc" for time/numeric. Active
column shows a ▲/▼ indicator in accent-blue.

Pager — "Load more" at table foot appends the next page rather than
replacing. Shows "Showing N of M" with a hint about client-filtered
narrows. Filter or sort change resets offset=0.

URL state — filter and sort sync to the URL via SvelteKit goto with
replaceState. hydrateFromURL() on mount restores from a pasted link.
Search query is intentionally NOT synced (changes per keystroke).

Refactor: loadUsers + searchUsers collapsed into loadList(reset).
buildQueryParams centralizes the URLSearchParams build for both the
fetch call and the URL sync. searchUsers() now just delegates to
onFilterChange so the search input and the filter bar share the same
reset/reload semantics.

Part of PLAN-1542. Frontend purely additive — no API or backend changes.

* fix: address Codex review on TASK-1549

Five real findings, all fixed:

1. statusFilter no longer auto-maps to has_workspaces in the query —
   that conflated server status precedence (disabled > no-workspace)
   so a disabled user with 0 workspaces would surface in the
   "no-workspace" bucket. statusFilter is now always applied
   client-side via applyClientStatusFilter against the row's
   server-computed status field. The "disabled" case still passes a
   server hint (disabled=true) to shrink the result set, but the
   client filter remains authoritative.

2. statusFilter ↔ hasWorkspacesFilter conflict resolved by point 1.
   The two controls are now genuinely independent — hasWorkspacesFilter
   only sets the dedicated has_workspaces param.

3. URL round-trip is now lossless. statusFilter has its own ?status=
   param rather than being smuggled through has_workspaces/disabled.
   All four buckets (active/inactive/disabled/no-workspace)
   serialize and re-hydrate identically.

4. loadMore() bumps offset BEFORE the fetch but rolls back on
   failure (try/catch + error-flag double-check). Added a re-entrancy
   guard so rapid clicks while a page is in flight no-op.

5. Sortable headers are now real <button>s inside the <th>, with
   aria-sort on the th. Keyboard-focusable + Enter/Space activation
   come from the native button; .sort-btn:focus-visible carries a
   visible outline.
2026-05-20 18:35:08 -04:00
xarmian 8a85eca713 feat(web): admin user table — cheap aggregation columns (TASK-1548) (#603)
* feat(web): add cheap aggregation columns to admin user table (TASK-1548)

Surfaces the per-user aggregations T1544 added to GET /admin/users:

- Workspaces (numeric, after Role)
- Storage (used bytes, formatted via the existing formatStorageBytes
  helper — same units the storage-override field accepts)
- Last Write (relative time, color-coded by writeRecency: green <7d,
  yellow <30d, red ≥30d, gray italic when never)
- Status pill (replaces the standalone "disabled" badge; renders for
  disabled / no-workspace / inactive; suppressed for "active" to keep
  the table calm)

Implementation:

- AdminUser interface in admin.svelte.ts gains last_write_at,
  workspace_count, storage_bytes, status — matching the server-side
  JSON shape from T1544.

- writeRecency() helper in +page.svelte buckets the timestamp into a
  CSS class. Visual half of the API's status pill; same age windows.

- .num-cell utility: right-aligned, tabular-nums so digits line up
  across rows (workspace_count and storage cells share it).

- edit-row colspan updated to 9 (cloud_mode) / 8 (self-hosted) to span
  the new columns.

Frontend purely additive — no API changes, no Go changes. T1549 wires
up pagination + sort + filter; T1550-T1555 build the modal.

Part of PLAN-1542.

* fix: address Codex review on TASK-1548

1. handleAdminGetUser response now includes last_write_at, storage_bytes,
   and status — matching handleAdminListUsers' shape. Without these,
   the row-merge after PATCH (role change, disable, plan edit) kept
   stale values; e.g. disabling an active user wouldn't show the new
   "disabled" status pill until the full list reloaded.

   - Store.UserStorageUsage: new helper that sums attachments across
     all workspaces owned by the user. Mirrors WorkspaceStorageUsage's
     definition.
   - Store.ComputeAdminUserStatusValue: exported wrapper around the
     existing private helper so handler code can compute the pill
     value without round-tripping through SearchUsers.

2. writeRecency threshold: 30d is now "stale" (inclusive) rather than
   "cold". Matches server-side computeAdminUserStatus which only flips
   to "inactive" on > 30 days. Eliminates a 1-day boundary mismatch
   between the recency color and the status pill.

3. A11y: write-recency cell now carries aria-label with the bucket
   name ("Last write: 12d ago (stale)"), so screen-reader users get
   the same meaning the color conveys.
2026-05-20 18:10:25 -04:00
xarmian 8fb46ca1b5 fix(web): surface open_children 409 + offer force override (BUG-1538) (#597)
* fix(web): surface open_children 409 + offer force override on status changes (BUG-1538)

The server's open-children guard (IDEA-1494) returns a structured 409
when transitioning a parent to a terminal status while it still has
non-terminal children. The web UI was catching this generically and
toasting "Failed to update status" — users couldn't tell why the
change was rejected or that --force exists.

Now the API client preserves the structured `details` payload, and a
singleton OpenChildrenDialog (mounted at +layout.svelte) lists the
blocking children as links to their detail pages, surfaces
hidden_blocker_count, and offers an "Override and mark <value>"
button that retries the PATCH with force=true — same semantics as
the CLI's --force flag.

Wired into the two PATCH sites that change the done-field: the
collection page's handleStatusChange (covers Board drag-drop + inline
status changes) and the detail page's updateField (FieldEditor on
the item detail page).

TASK-1539.

* fix(web): address self+codex review (round 1)

- Item moves (POST /move) also hit the open-children guard server-side.
  Wire the same modal + force-override path through api.items.move() and
  handleMove() on the detail page (Codex finding 1).
- OpenChildrenDialog: add a focus trap so Tab / Shift-Tab cycle within
  the modal, and restore focus to the previously-focused element on
  close (Codex finding 2 + self-review a11y note).
- Simplify the nested try/catch in handleStatusChange / updateField:
  branch on isOpenChildrenError early so cancelling the modal stops
  emitting console.error noise and the retry-failure path stays
  distinct from the original-guard path (self-review nits 2 + 3).
- updateField: assign item = { ...item } on cancel to force a fresh
  prop pass to FieldEditor in case it caches by identity.

BUG-1538 / TASK-1539.

* fix(web): capture full route identity in handleMove pre-modal (Codex round 2)

Captures sourceItem/sourceWs/sourceUsername at move kickoff so a
confirmed force-retry after navigation can't move the wrong item.
Adds navIfStillCurrent helper that gates the success-path goto on
identity match — stale resolutions complete silently rather than
yanking the user away from the new page.

BUG-1538.

* fix(web): also gate handleMove navigation on route params (Codex round 3)

Compare page.params.{collection,slug} in addition to item.id and
workspace — during same-component navigation `item` can briefly
still hold the source object after the URL has advanced. Route-
param check closes that race.

BUG-1538.
2026-05-19 20:54:55 -04:00
xarmian e27f805ffc feat(web): unify dashboard onboarding banners around needs_onboarding signal (TASK-1530) (#594)
IDEA-1516 Phase 3. The pre-IDEA-1516 design split workspace onboarding
guidance across two banners — OnboardingIdeaBanner (gated on the
retired IDEA-1 / BACK-1 / FEAT-1 seed-item pattern from PLAN-1496) and
OnboardingChecklist (gated on a totalItems === 0 heuristic that
predates the canonical needs_onboarding flag from TASK-1504). Both
fired competing CTAs on the same screen; neither read the canonical
signal.

Backend (internal/server/handlers_dashboard.go):
- Add `NeedsOnboarding bool json:"needs_onboarding"` to
  DashboardResponse, populated via the existing
  Store.WorkspaceHasUserCreatedItems EXISTS query (same predicate
  AgentBootstrap.NeedsOnboarding uses). Web reads it from the
  dashboard fetch the page already does — no second round-trip
  against the heavier bootstrap endpoint.

Frontend:
- Add `needs_onboarding: boolean` to TS DashboardResponse type
- Delete OnboardingIdeaBanner.svelte entirely (signal retired,
  no remaining consumers); the back-end onboarding_seed field
  stays for now per spec — separate cleanup
- Delete OnboardingChecklist.svelte; replace with
  OnboardingNudgeBanner.svelte — single message + "Connect agent →"
  CTA that opens the workspace's already-mounted
  ConnectWorkspaceModal. Dismissible, preserves the existing
  `pad-onboarding-dismissed-{wsSlug}` localStorage key so users who
  dismissed the old checklist don't get re-prompted
- Workspace +page.svelte: collapse the two banner blocks into one
  gated on `needsOnboarding && !onboardingDismissed`; reshow button
  follows the same signal. Drop the now-orphaned `.connect-card`
  CSS — its function is subsumed by the banner's CTA. Drop the
  unused OnboardingIdeaBanner / OnboardingChecklist imports and the
  `onboardingSeed` derived state

Smart-suppression deferred to a follow-up. The existing
api.workspaces.claimCode endpoint returns suppression info but
generates a real claim code as a side effect in the not-suppressed
case — calling it on every workspace page-load with needs_onboarding=true
is awkward. The CTA still opens the modal, which renders its own
suppression state correctly; users get the right experience with one
extra click on the rare suppressed case. A dedicated read-only
GET /workspaces/{ws}/connect-status endpoint is a separate piece
of work.
2026-05-19 13:17:57 -04:00
xarmian b969dd7557 feat(web): consolidate workspace-create surfaces — redirect /console/new to modal (#593)
* feat(web): consolidate workspace-create surfaces — redirect /console/new to modal (TASK-1529)

IDEA-1516 Phase 2. The modal is the single create-workspace surface
now (Phase 1 redesign already shipped in TASK-1528/1526); the
/console/new dedicated page from TASK-1506 / PR #579 becomes dead
duplication.

- Replace /console/new/+page.svelte with a +page.ts that
  `throw redirect(307, '/console?openCreate=1')`. 307 preserves
  request-method semantics; in SPA mode (adapter-static + fallback
  index.html) the load function runs in-browser and SvelteKit handles
  the redirect as a client-side navigation, so direct visits and
  bookmarks both end up at /console with the create modal opened.
- /console/+page.svelte onMount reads the ?openCreate=1 query param,
  fires `uiStore.openCreateWorkspace()`, then scrubs the param via
  goto({ replaceState: true, noScroll: true, keepFocus: true }) so a
  refresh doesn't re-open the modal.
- Replace the two `<a href="/console/new">` links (header CTA +
  empty-state) with `<button onclick={openCreateWorkspace}>` —
  direct callers skip the route round-trip entirely. Added the
  necessary button resets (border:none / cursor:pointer / font:inherit)
  to the existing classes so visual rendering is identical.
- Verified no other refs to /console/new in web/src.

* fix(web): mount CreateWorkspaceModal on console pages per Codex review (round 1)

Codex flagged that uiStore.openCreateWorkspace() from /console's new
CTA buttons (+ /console/new redirect) sets state with no observer —
the modal lives in the non-console branch of +layout.svelte and was
never mounted on console routes. Split the isConsolePage case into
its own branch that renders children + CreateWorkspaceModal +
ToastContainer (toast surface needed for create-failure feedback).
2026-05-19 12:56:20 -04:00
xarmian f8af6cb889 feat(web): blank-as-default workspace modal + auto-open Connect modal post-create (TASK-1528, TASK-1526) (#592)
Combines IDEA-1516 Phase 1 (CreateWorkspaceModal redesign) with PLAN-1519
Phase F (Connect-modal auto-open wiring) — Phase 1's callback API and
Phase F's consumer are the same integration surface, so they ship
together to stay PR-sized per CONVE-2.

Phase 1 — CreateWorkspaceModal redesign (TASK-1528):
- Default selection flips from `startup` to `blank`
- New primary "Start blank" card per IDEA-1516 §2 (recommended path,
  agent-driven onboarding)
- Templates section collapsed by default; expanding pre-selects
  `startup` to match pre-redesign behavior for users who explicitly
  want a template
- Existing grouped categories preserved inside the section, with
  Blank filtered out (it's the primary card now)
- Footnote on the expanded section pointing at `/pad onboard`
- New optional `onWorkspaceCreated` callback prop fired after
  successful create OR import, before close+goto

Phase F — auto-open wiring (TASK-1526):
- New `uiStore.requestConnectAfterNavigate(slug)` + matching
  single-shot `consumeConnectAfterNavigate()` getter — mirrors the
  existing `requestQuickAdd` / `clearQuickAddRequest` pattern in the
  same store
- `+layout.svelte` wires the modal's `onWorkspaceCreated` callback
  to `uiStore.requestConnectAfterNavigate(ws.slug)`
- Workspace `+page.svelte` consumes the signal in a dedicated effect
  (kept separate per CONVE-606) when its slug matches, opening the
  already-mounted ConnectWorkspaceModal
- Import flow gets the same treatment — claim-code value is
  independent of how the workspace got created

No backend changes; Phase E (TASK-1525) already shipped the claim-code
generation + smart suppression that the auto-opened modal renders.
2026-05-19 08:00:48 -04:00
xarmian b801867053 chore(web): npm audit fix — svelte 5.55.8, devalue 5.8.1, mermaid 11.15.0 (#589)
* chore(web): npm audit fix — patch-bump svelte, devalue, mermaid

Resolves three advisories surfaced by the Web CI job (and visible on
recent main commits before this):

- svelte:    5.55.5 → 5.55.8 (moderate × 4: SSR XSS via spread,
             hydratable Promise XSS, DOM clobbering, ReDoS in
             <svelte:element>)
- devalue:   5.6.x → 5.8.1   (high: DoS via sparse array deser)
- mermaid:   11.14 → 11.15.0 (moderate × 4: Gantt infinite-loop DoS,
             classDef CSS/HTML injection, config CSS injection)

All three are in-range patch bumps — package.json untouched, only the
lockfile changes. No major bumps, no API churn.

Tiptap deliberately untouched: @tiptap/core, @tiptap/extension-
collaboration, @tiptap/y-tiptap stay at 3.22.5 — the CLAUDE.md
lockstep rule (coordinated bumps + Y.Doc schema-version bump) only
applies when those three move together, and nothing here does.

Verified:
- npm audit: 0 vulnerabilities
- npm run check: 0 errors (existing 6 warnings unchanged)
- make build + make install: clean, server boots and serves the new
  bundle.

* test(oauth): TestConsent_ApproveWithSpecificWorkspaces locates connection by shape

The test asserts the persisted oauth_connections row from a specific-
workspaces consent (allowed_workspaces=[alpha,beta]) has
AllCurrentWorkspaces=false and the right slug list. It indexed via
conns[0], which only worked when the approve flow was the most-recent
connection — but the test also calls runAuthCodeFlow above the
assertion to mint a bearer for the introspect call, and that helper
posts allowed_workspaces=["*"] → wildcard connection. With
ListUserOAuthConnections ordered ConnectedAt DESC, conns[0] is now the
wildcard bearer row, not the alpha+beta row this test is verifying.

We can't move the bearer-mint below the assertion (the introspect call
needs the bearer first), and the auth-code response doesn't surface the
request_id we'd need to look up the right connection directly. The
specific-workspaces flow is the only one in this test with
AllCurrentWorkspaces=false, so locate by that shape — surgical fix that
matches the test's actual intent.

Verified with `go test -count=3 -run TestConsent_ApproveWithSpecificWorkspaces`
(deflakes across orderings) and the full ./internal/server suite.
2026-05-18 17:17:42 -04:00
xarmian bfce069f28 fix(web,build): search palette hang on numeric query + graceful SSE shutdown (BUG-1531) (#588)
* fix(web,build): search palette hang on numeric query + graceful SSE shutdown (BUG-1531)

CommandPalette's reactive `$effect` subscribed to every workspace's
`localSearch.epoch` + `localIndex.bootstrapStateFor`. Bare-digit queries
short-circuit to `exactItemNumberLookup` (synchronous, very fast) and
stacked re-fires of the effect inside one microtask tick whenever an SSE
delta arrived — Svelte tripped `effect_update_depth_exceeded` and the
palette froze. Treat bare-digit queries the same as `body:` queries
(skip the subscription reads) and wrap `doSearch()` in `untrack()` so
its internal reactive reads can't smuggle hidden dependencies into the
effect.

The SSE churn that fanned the loop was rooted in `make install` using
`killall -9` — SIGKILL drops every open SSE stream mid-chunk so every
browser tab logs `ERR_INCOMPLETE_CHUNKED_ENCODING` and reconnects.
Switch to SIGTERM + 5s wait + SIGKILL fallback so the server's existing
graceful-shutdown path (cmd/pad/main.go:811-857) actually runs and the
http.Server writes a final 0-chunk on each open stream.

Follow-up tidy-ups (unchecked write errors in writeSSEEvent, link the
30s keepalive to the 120s IdleTimeout in code) tracked in BUG-1532.

* fix(web): track workspace slug in palette $effect per Codex review (round 1)

After wrapping doSearch() in untrack(), the workspaceStore.current?.slug
read that doSearch performs at line 209 no longer registered as a
tracked dep of the search effect. The non-body / non-bare-digit branch
still reads the slug via localIndex.bootstrapStateFor(...), so workspace
switches re-fire the effect for that branch — but body: and bare-digit
queries skip that block entirely. Without an explicit slug subscription
they wouldn't re-dispatch on workspace switch; an in-flight server
response would land stale, get discarded by isSameDispatch(), and
loading could stick true.

Hoist `void workspaceStore.current?.slug` into the unconditional void
block so all four query shapes re-fire on workspace switch.

Refs BUG-1531.

* chore: gofmt handlers_claim_code_test.go

Drive-by formatting fix to unblock CI on this PR. The file landed
slightly unaligned in #586 (TASK-1525) — gofmt straightens the struct
tag column on claimCodeResponse.
2026-05-18 15:25:27 -04:00
xarmian ab5b68c7c3 fix(connect): correct self-host copy in claim-code disabled state (TASK-1525 follow-up) (#587)
The `claim_disabled` path only fires on self-host deployments without
PAD_MCP_PUBLIC_URL — the OAuth server (and with it the claim secret
and the OAuth-grant model itself) only mounts under that env var. In
that configuration:

  - Agents authenticate as the user via session tokens from
    ~/.pad/credentials.json — stdio MCP (`pad mcp serve`) and the
    CLI both inherit it.
  - The agent sees every workspace the user is a member of by
    default. There is no per-workspace OAuth grant to claim into.
  - /console/connected-apps is empty by definition (it lists OAuth
    grants).

So the prior copy ("Use the MCP tab to authorize an agent from
scratch") was misdirection on two counts:

  1. The MCP tab is filtered out entirely when mcpPublicUrl is empty
     (visibleTabs). The button it pointed at didn't exist.
  2. There's no further "setup" required — the agent already has
     access.

Two fixes:

  - Rewrite the disabled-state copy to tell self-host users they're
    already done: "No claim code needed on this deployment. Agents
    connected via the CLI or stdio MCP use your user session and
    already have access to every workspace you're a member of."
  - Hide the "Connected agents →" footer link when mcpPublicUrl is
    empty so we don't point self-host users at an empty page.

The Open Connected apps link inside the suppression-state panel
stays — suppression only fires when an active OAuth grant covers
the workspace, which by definition only happens on cloud / remote-
MCP-enabled deployments.
2026-05-18 10:49:19 -04:00
xarmian fc6afd01be feat(connect): unified Connect-to-agent modal + claim-code endpoint (TASK-1525) (#586)
* feat(connect): unified Connect-to-agent modal + claim-code endpoint (TASK-1525)

Phase E of PLAN-1519. Repurposes the avatar-menu "Connect a project…"
modal as a one-stop hub where users can connect ANY agent surface
(claim-code → existing OAuth grant, fresh MCP OAuth, or local CLI)
to the current workspace.

Backend
- GET /api/v1/workspaces/{slug}/claim-code — generates a stateless
  6-digit HMAC claim code (re-uses the verifier's secret + bucket
  math) for the calling member, OR reports `suppressed: true` when
  smart-suppression detects the workspace is already covered by one
  of the user's active OAuth connections (wildcard OR explicit
  allow-list rows). Returns `expires_at` at the current bucket
  boundary so the UI can drive a countdown.
- store.IsWorkspaceCoveredForUser — single indexed query against
  oauth_connections + oauth_*_tokens; filters by ACTIVE tokens so a
  dangling revoked connection row doesn't suppress fresh modals.
- Tests cover 412 (disabled), 404 (non-member), 200 + matching code,
  wildcard suppression, explicit-allow-list suppression, and the
  revoked-connection-doesn't-suppress invariant.

Frontend
- ConnectWorkspaceModal rewritten as a tabbed unified modal:
  - Agent (claim code) — fetches on activate, live countdown,
    auto-refetches at bucket roll-over, renders smart-suppression
    panel that links to /console/connected-apps, and renders the
    locked prompt block
    `Authorize the pad workspace '<slug>' with claim code <code>.`
    per IDEA-1517 §4.
  - MCP setup — subsumed from the now-deleted ConnectMCPModal: URL
    block + client-card grid linking to per-client docs.
  - CLI — existing install + `pad init` flow, unchanged.
- Default tab: Agent when mcpPublicUrl is set; CLI when not. MCP tab
  hidden entirely on self-host without a public MCP URL.
- ConnectBanner simplified: single modal state, generic "Connect an
  AI agent to this workspace" copy, no MCP/CLI dual-modal branching.
- ConnectMCPModal.svelte deleted (fully subsumed).
- API client gets `workspaces.claimCode(slug)` + `ClaimCodeResponse`
  TypeScript type.
- TopBar + workspace home callsites pass `mcpPublicUrl` from
  authStore so the unified modal can pick the right default tab.

Verification
- go build ./... clean
- go test ./... — all packages green (server + store)
- cd web && npm run build — clean

Parent: PLAN-1519. Phases A-D already shipped (oauth_connections
schema, MCP claim action, /authorize redesign, connections-page
mutation UI); this lands Phase E. Phase F (TASK-1526) will wire
post-create auto-open from IDEA-1516's new-workspace modal; Phase G
(TASK-1527) is cross-agent paste validation of the locked prompt
string.

* fix(connect): require membership at claim-code generation; guard modal against stale-response races per Codex review (round 1)

1. Guest-grant generation gap. RequireWorkspaceAccess admits item-grant
   guests who aren't workspace members; claim-code REDEMPTION requires
   full membership. Generating without the same check handed guests a
   valid-looking code + prompt that the claim endpoint always 404s.
   Add an explicit GetWorkspaceMember check after getWorkspace and
   return 403 not_a_member to fail closed on the same response shape
   the redemption path would have used.

2. Stale-response race in the modal. ConnectWorkspaceModal stays
   mounted across workspace switches (TopBar reuses the same
   instance), so an older claimCode fetch can resolve AFTER a newer
   one and stomp claimState with suppression or a code for the wrong
   workspace. Add a monotonic seq + captured-slug guard mirroring the
   refreshHasAgentActivity pattern already in ConnectBanner.

Test additions:
- TestHandleWorkspaceClaimCode_GrantOnlyGuest_403 asserts a non-member
  authenticated caller never gets a 200 + code from the generation
  endpoint.
2026-05-18 10:25:08 -04:00
xarmian 26aa800b3b feat(oauth): connected-apps mutation endpoints + edit UI (TASK-1524) (#585)
* feat(oauth): connected-apps mutation endpoints + edit UI (TASK-1524)

Phase D for PLAN-1519. Adds four mutation endpoints under
/api/v1/connected-apps/{id}/... and extends the console page with
an inline Edit panel per connection card.

Backend (internal/server/handlers_connected_apps.go + server.go)
- PATCH .../name           — rename, trim + cap at 120 chars
- PATCH .../flags          — atomic set of may_create / all_current /
                              include_future (rejects toggling
                              all_current=false when the join table
                              is empty — empty-allow-list invariant
                              from IDEA-1517 §3 Acceptance)
- POST  .../workspaces     — add workspace; membership-checked, 404
                              uniform when the user isn't a member
                              (no enumeration leak)
- DELETE .../workspaces/{slug} — remove; idempotent for missing
                              slugs; rejects last-workspace removal
                              when all_current=false (same orphan
                              guard as the flags handler)

All four route through requireConnectionOwner which returns the
same 404 envelope for non-owned connections as the existing
Revoke endpoint. Each handler echoes the updated DTO so the page
can re-render in place; respondWithConnection handles both
active-token chains (via ListUserOAuthConnections) and connections
without token rows (direct fetch of oauth_connections + the access
projection).

DTO (connectedAppDTO) gains name + the three scope flags. Model
already carried the fields (TASK-1522).

Frontend (web/...)
- ConnectedApp TS type extended; api.connectedApps gains
  rename/updateFlags/addWorkspace/removeWorkspace methods.
- Connections page: Edit button per card opens an inline panel
  with: connection name input (debounced save), three scope-flag
  toggles (auto-save), allow-list chips with X-to-remove plus a
  workspace picker that lists memberships not already in the list.
- Last-workspace removal disabled at the UI level (chip-remove
  disabled when list length <= 1); API enforces the same invariant
  if a tampered call slips through.
- Workspaces fetched lazily on first Edit open (cached for the
  page lifetime).

Tests
- 7 handler tests cover happy paths + edge cases:
  - rename trims/caps, non-owner 404
  - flags happy path + empty-allow-list block
  - add workspace happy path + non-member 404
  - remove workspace happy path + last-blocked + idempotent missing
- loginTestUserAs helper to seed a second user for the non-owner
  case (the existing loginTestUser hardcodes a single email).

Parent: PLAN-1519.

* fix(oauth): wildcard→specific toggle now works after pre-stage per Codex review (round 1)

PR #585 round 1 caught that switching all_current_workspaces from
true to false was effectively impossible: the flags handler's
empty-allow-list guard called GetOAuthConnectionAccess, which
intentionally short-circuits on wildcard and reports zero slugs.
Even with join rows present, the guard always tripped. The UI
compounded the issue by hiding the allow-list editor while in
wildcard mode, so users had no way to pre-stage workspaces.

Backend fix: new Store.ConnectionWorkspaceCount(requestID) returns
the raw join-row count regardless of the parent's wildcard flag.
The flags handler now uses this; the remove-workspace handler's
"orphan guard" also routes through the new count (plus an
IsConnectionWorkspaceAllowed probe so a no-op removal of a slug
that isn't even in the list doesn't trip the guard).

Frontend fix: the allow-list editor renders unconditionally inside
the Edit panel. While wildcard is on, an "Inert while wildcard is
on" badge clarifies that staged workspaces don't take effect until
the user flips the flag. Chip-remove disabled only when actively
in specific mode AND about to drop to zero — wildcard-mode removes
are always allowed.

Added TestHandleUpdateConnectedAppFlags_WildcardToSpecific_AfterPrestage
as the regression guard: seeds a wildcard connection, adds one
workspace via the API, then asserts the flag flip succeeds and
the resulting DTO carries the pre-staged slug.

Parent: PLAN-1519.

* fix(oauth): staged-while-wildcard rows now visible per Codex review (round 2)

PR #585 round 2 caught that the round-1 fix (always-rendered
allow-list editor + Backend ConnectionWorkspaceCount) was
incomplete: pre-staging a workspace while wildcard=true succeeded
on the server but the response DTO still suppressed the staged
slugs (ListUserOAuthConnections + respondWithConnection both set
AllowedWorkspaces=nil when AllCurrentWorkspaces=true). The UI
rendered "No workspaces staged" even after a successful add, so
users had no way to see or remove a mistaken staged row.

Backend fix: new Store.ListConnectionWorkspaceSlugs returns the
join table's slugs regardless of the wildcard flag.
ListUserOAuthConnections + respondWithConnection both route
through it now. The hot-path GetOAuthConnectionAccess still
short-circuits on wildcard (correct for the introspection path —
when wildcard is on, slugs are irrelevant for gating); the read-
for-display path needs to surface them.

Frontend fix: isAnyWorkspace now reads the all_current_workspaces
flag directly (drives the "Any workspace" badge), independent of
the slug list. The slug list drives the edit panel chips. Legacy
fallback for missing-flag wire shapes preserved.

Added TestHandleAddConnectedAppWorkspace_VisibleUnderWildcard as
the regression guard: seeds a wildcard connection, adds a
workspace, asserts the slug appears in DTO.AllowedWorkspaces
while AllCurrentWorkspaces stays true.

Parent: PLAN-1519.
2026-05-18 08:33:39 -04:00
xarmian 403cf6b149 feat(web): Blank-first picker + post-create /pad onboard guidance (TASK-1506) (#579)
* feat(web): surface Blank as featured card + post-create /pad onboard guidance (TASK-1506)

The console workspace-creation page (web/src/routes/console/new) now
makes the agent-driven flow first-class on both the picker and the
post-create screen:

Picker:
- Blank template renders as a leading "featured" card above the
  grouped category list, with an "Agent-driven" badge so its
  positioning is intentional rather than visually accidental.
- The grouped category iteration filters Blank out so it doesn't
  render twice. Older server builds that don't ship Blank degrade
  silently — the featured card just doesn't appear.

Post-create success state:
- Replaces the pre-task immediate goto-redirect that took users
  straight to the dashboard, hiding the canonical /pad onboard
  entry point.
- Branches on template:
  - Blank: prominent onboard card with a heading, a copy/paste
    snippet (<pre><code>/pad onboard</code></pre>), and a help line
    pointing at the agent-connection docs.
  - Non-blank: subtle one-paragraph affordance — "want to customize
    further? run /pad onboard."
- "Open <workspace>" CTA renders as an <a> styled like .submit-btn
  so the success screen is one click from the workspace dashboard.

Implementation notes:
- Script uses $state for createdWorkspace + createdTemplate to swap
  the layout, $derived for blankTemplate / grouped / username,
  $derived.by for workspaceUrl (multi-statement derivation).
- 'goto' import removed (no longer needed — the swap replaces the
  redirect).

Verification:
- svelte-autofixer: 0 issues, 0 suggestions.
- svelte-check: 0 errors (pre-existing warnings in unrelated files).
- npm run build: clean.
- go test ./... + golangci-lint: clean (Go side untouched).

Parent: PLAN-1496.

* fix(web): repoint Connect-MCP link to existing getpad.dev docs (Codex round 1)

P2 finding on PR #579: /docs/agents is not a route in this app (no
/docs prefix exists; only the marketing site at getpad.dev owns the
docs surface). Clicking the link from the success state would land
on the app's 404 page.

Repointed to https://getpad.dev/docs — the same external URL the
+error.svelte page already uses for 'Browse docs' (known-good and
known-stable). Added target/rel attrs matching the existing external
getpad.dev links elsewhere in the codebase.

Parent: PLAN-1496, addressing Codex round 1 on PR #579 / TASK-1506.
2026-05-17 16:23:05 -04:00
xarmian 8094883869 feat(links): cross-workspace wiki-link resolution (IDEA-1492) (#568)
* feat(links): cross-workspace wiki-link resolution (IDEA-1492)

Adds [[workspace::REF]] and [[workspace::REF|Display]] wiki-link syntax
that resolves cross-workspace, plus a Go route GET
/{username}/{workspace}/ref/{REF} that 302-redirects to the canonical
item URL. 404 leaks no info about workspace existence — malformed refs
short-circuit before the workspace lookup, and access-denied returns 404
not 403.

Frontend (web/src/lib/utils/markdown.ts):
- renderMarkdown recognizes the workspace-prefix form and emits
  cross-workspace anchors (class doc-link cross-workspace) pointing at
  the resolver route. Same-workspace prefix is stripped and behaves
  identically to the legacy [[REF]] form.
- wikiLinksToMarkdown emits the resolver URL for cross-workspace storage
  and same-workspace items resolve through the in-memory list.
- markdownToWikiLinks rolls /<user?>/<ws>/ref/<REF> URLs back to
  [[workspace::REF]] (or |Display when display text differs from the
  ref). Legacy same-workspace round-trip is preserved.

Backend (internal/server/handlers_ref_resolver.go):
- Validates the ref shape before the DB hit (no oracle).
- Reuses resolveWorkspace + GetItemByRef for ACL + lookup.
- refResolverItemVisible mirrors requireItemVisible without depending
  on RequireWorkspaceAccess middleware (this route is reachable
  outside the workspace-scoped route group).
- Redirect target matches itemUrlId() so the post-redirect URL is
  indistinguishable from a direct in-app navigation.

Tests: 302 success, 404 unknown-workspace, 404 unknown-ref, 404 on a
matrix of malformed refs (including url-encoded traversal). The
no-access matrix is partially covered — the existing test surface
doesn't compose a multi-user ACL fixture, so production-grade
"member of A probes B" is gated by the real auth middleware stack and
documented in TestRefResolver_NoAccess_DocumentsPreSetupBypass.

* fix(links): codex round-1 fixes for cross-workspace resolver (IDEA-1492)

P1.1 — Reserve "ref" as a collection slug. A collection slug of "ref"
would shadow every item URL under the resolver's /{u}/{ws}/ref/...
route. Added to reservedCollectionSlugs in internal/store/collections.go
so it auto-suffixes to "ref-collection", matching the existing
treatment of settings/activity/roles/etc.

P1.2 — Extract checkItemVisible as a context-free helper. The previous
refResolverItemVisible silently diverged from requireItemVisible by
ignoring direct collection grants and member_collection_access for
restricted members — a member with "specific" access on collection A
plus a direct grant on collection B would 404 through the resolver
even though they could see the item via the API. checkItemVisible now
replays the same rules requireItemVisible inlined; requireItemVisible
is now a thin wrapper, and the resolver derives its workspace role via
resolverWorkspaceRole and delegates to checkItemVisible. Drift between
the two paths is structurally impossible.

P2.1 — Cross-workspace round-trip preserves explicit display overrides.
The strip condition was `displayText === ref`, which would drop the
override on [[other::TASK-1|TASK-1]] — then re-rendering would emit
the default `other::TASK-1` and silently change visible link text.
Fixed: only strip when displayText matches the actual render default
`${ws}::${ref}`.

P2.2 — Two-segment route /{workspace}/ref/{REF}. TimelineCommentCard
and CommentThread call renderMarkdown without a username, so links in
timeline comments emit the two-segment href shape. Registered the
shorter route against the same handler; when the URL-path username is
absent the handler falls back to the workspace owner's username via a
new resolverOwnerUsername helper.

Sanity sweep:
- Dropped refItoa wrapper; use strconv.Itoa directly. The non-test
  `itoa` collision was a test-only `itoa` in handlers_admin_users_test.go,
  not a real symbol in non-test builds.
- Removed encodeURIComponent on workspace + ref in renderMarkdown.
  parseCrossWorkspaceBody validates both against URL-safe regexes, and
  wikiLinksToMarkdown doesn't encode — both functions now emit
  identical bytes.

Tests:
- TestRefResolver_RejectsRefAsCollectionSlug — pins the reservation.
- TestRefResolver_TwoSegmentRouteResolves — both URL shapes resolve;
  two-seg synthesizes the owner username.
- TestRefResolver_RestrictedMemberWithCollectionGrant — the
  codex-flagged ACL case (restricted member + collection grant on a
  different collection) now resolves to 302, not 404. Frontend test
  for the round-trip override fix is documented as a gap (no vitest
  infra in the repo).

* fix(links): codex round-2 fixes for cross-workspace resolver (IDEA-1492)

P1.1 — Tokenized roles bypass user-nil check. Pre-fix, checkItemVisible
rejected (nil user, "editor") tuples — exactly what RequireWorkspaceAccess
synthesizes for legacy workspace-scoped API tokens — false-404'ing every
requireItemVisible-gated handler hit by those tokens. Reordered the
checkItemVisible rules so any tokenized role (owner / editor) bypasses
the user-nil guard. checkItemVisible regression test (no HTTP layer)
pins the bypass.

P1.2 — System collections folded into item-grants branch. The pre-round-1
guestResourceFilterCore unioned ListSystemCollectionIDs into the
fullCollIDs set; the round-1 refactor dropped that union, so a
restricted member with conventions/playbooks (system collection) access
plus an unrelated item grant could LIST system items but 404 on
detail-fetch / ref-resolve. Restored the union inside checkItemVisible's
item-grants branch (non-guest path only — matches the original
guestResourceFilterCore semantics).

P1.3 — Empty owner-username 404s instead of emitting broken redirect.
When the workspace owner has no username on file (pre-setup ownerless
workspaces, legacy accounts), the synthesized redirect target became
`"/" + "" + "/" + slug + ...` → `//slug/...` — a protocol-relative URL
browsers interpret as a network-path reference. Now 404s via
refResolverNotFound rather than emitting the malformed Location header.

P1.4 — URL shape changed to /-/r/{workspace}/{ref} (Option B). Pre-fix,
the resolver lived at /{username}/{workspace}/ref/{ref}, which would
intercept item URLs in workspaces with pre-existing `ref`-slugged
collections (upgraded data; the round-1 reservation only blocks NEW
creates). Picked Option B over a migration because the feature is
unshipped, the new shape is more defensive (no future risk under any
collection slug), and the only cost is the frontend emit-shape change.
The leading `/-/r/` prefix can never collide with a user-namespace URL
because username + slug grammar both require letter-led. Frontend
renderMarkdown, wikiLinksToMarkdown, and markdownToWikiLinks all emit
and parse the new shape; the round-1 collection-slug reservation stays
as defense in depth.

Tests:
- TestCheckItemVisible_TokenizedRoleAllowsNilUser — P1.1 regression.
- TestRefResolver_RestrictedMemberWithSystemCollection — P1.2 regression.
- TestRefResolver_PreSetupBypass — P1.3 (ownerless workspace returns
  404, not a broken `//slug/...` redirect).
- TestRefResolver_URLShapeNonOverlap — P1.4 (resolver doesn't intercept
  `/{user}/{ws}/ref/{slug}` URLs).
- Existing TestRefResolver_* updated to the /-/r/ shape; the previous
  two-segment fallback test is removed (the new URL shape has no
  username component, so there's no two-segment vs three-segment
  distinction to test).

* fix(links): scope round-2 bypass to nil user (Codex round-3 P1)

Round-2's checkItemVisible bypass for role in {"owner", "editor"} fired
unconditionally, including for real authenticated members. Result: a
member with workspace role "editor" and collection_access="specific"
short-circuited the per-collection filter — they could GET/PATCH/DELETE
items in collections their member_collection_access list excluded.

Scoped the bypass to the tokenized-nil-user case only:

    if user == nil && (role == "owner" || role == "editor")

This is the exact set the bypass was supposed to address — fresh-install
mode (UserCount==0, role="owner") and legacy workspace-scoped API
tokens (tokenWorkspaceID matches, role="editor"). Both paths set
currentUser to nil; both are authorized by RequireWorkspaceAccess
before checkItemVisible runs.

Real authenticated members with the same roles now correctly fall
through to the existing per-collection visibility filter. Workspace
owners with default access still pass via the rule-4 "all access"
short-circuit (member.CollectionAccess == "all"); restricted editors
are now gated as intended.

Updated the rule-1 doc comment to make the scope-to-nil-user discipline
explicit — the prior wording conflated the tokenized and authenticated
paths, which is what led to the over-broad bypass.

Test: TestCheckItemVisible_AuthenticatedEditorWithRestrictedAccess
seeds a real editor with collection_access="specific" granting only
collection A, asserts visibility on a collection-B item returns false,
and adds a sanity assertion that the same editor sees collection-A
items. The existing TestCheckItemVisible_TokenizedRoleAllowsNilUser
still passes — it covers the (nil, "editor") tuple the corrected
bypass still allows.

Direct callers of checkItemVisible (grep): only requireItemVisible
(server.go) and resolverItemVisible (handlers_ref_resolver.go). Both
pass real (user, role) from request context, so the narrower scope
doesn't break any prior-green path.

* fix(links): allow digit-leading workspace slugs in xw wiki-links

Frontend WORKSPACE_SLUG_PATTERN was tighter than store.slugify (the
canonical rule): slugify keeps digit-leading inputs (e.g. "2026
Roadmap" → "2026-roadmap") but the frontend regex rejected them. Effect:
`[[2026-roadmap::TASK-1]]` fell through as a legacy title link, and
`/-/r/2026-roadmap/TASK-1` URLs didn't round-trip back to wiki syntax.

Two regex hunks, no behavior change beyond accepting the digit-led
case:

- WORKSPACE_SLUG_PATTERN: ^[a-z][a-z0-9-]*$ → ^[a-z0-9][a-z0-9-]*$
- markdownToWikiLinks reverse-regex workspace class: same widening

Stale doc-comment citing the old pattern updated to match.

Collection-slug grammar stays letter-led (the upstream rule differs;
only workspace slugs accept digit-led). Only functional consumer of
WORKSPACE_SLUG_PATTERN is parseCrossWorkspaceBody, which uses the
match boolean — no other downstream code relied on the leading-letter
constraint (Codex round-4).
2026-05-16 18:20:17 -04:00
xarmian 312cf06ce5 fix(web): merge defaults in parseSettings/parseSchema (IDEA-1487) (#564)
* fix(web): merge defaults in parseSettings/parseSchema on successful parse (IDEA-1487)

parseSettings and parseSchema only merged defaults in the catch branch.
Post-PR #562 migration backfilled NULL collections.settings to '{}', so
JSON.parse succeeds and returns a bare object — downstream consumers
read settings.layout as undefined (rendering 'layout-undefined') and
schema.fields.find as a TypeError on any collection with bare '{}'.

Merge SETTINGS_DEFAULTS / SCHEMA_DEFAULTS into the parsed object in both
branches. Explicit user-supplied fields still override defaults.

Note: QuickActionsMenu spreads parseSettings() back to the wire on edit,
so first quick-action save on a previously-bare collection now persists
{layout:'balanced', default_view:'list'} alongside quick_actions. Left
as-is — defaults migrating to wire is harmless and matches what the UI
was already rendering. Reviewer flag, not a regression.

* fix(web): fresh defaults per parse call to avoid shared mutable state (IDEA-1487 R1)

The module-level SCHEMA_DEFAULTS / SETTINGS_DEFAULTS consts introduced in
8c177d0 hold a `fields: []` array that is copied by reference under shallow
spread. Any caller that mutates `.fields` in place (push/splice/sort) on a
parsed result that fell through to the default would pollute the shared
array for every subsequent parseSchema call.

No current caller mutates, so this is latent — but defense-in-depth at the
exact boundary IDEA-1487 exists to harden. Switch to factory functions that
return a fresh object (with a fresh nested array) per call.

* fix(web): fresh array on getTerminalOptions fallback (IDEA-1487 R2)

getTerminalOptions returned the module-level DEFAULT_TERMINAL_STATUSES
array by reference on the fallback path. Same shared-mutable-state hazard
as R1's parseSchema fix — latent today (only consumer iterates), but a
defense-in-depth gap at the same boundary. Spread on return so each
caller gets a fresh array.
2026-05-15 21:44:18 -04:00
xarmian 7c663a3d3f feat(collections): add blank workspace template + retire auto-upgrade hook (IDEA-1479) (#560)
* feat(collections): add blank workspace template (IDEA-1479)

Introduces a `blank` workspace template that seeds only the two system
collections (Conventions, Playbooks) — no Tasks/Ideas/Plans/Docs, no
seeded items, no starter conventions or playbooks. Solves the
agent-self / non-template-fit use case where the existing software
templates leave undeletable ghost collections in the workspace.

Adds a new `CategoryCustom` ("Custom") top-level category so the blank
template doesn't mis-group with `startup` / `scrum` / `product`.
Category is appended last in `CategoryOrder` so it doesn't displace
recommended-path templates in the picker.

Tests:
- TestBlankTemplateShape — exactly 2 system collections, no seeds.
- TestBlankTemplateExcludesSoftwareCollections — no tasks/ideas/plans/docs.
- TestBlankTemplateAppearsInPicker — surfaces under a Custom group.
- TestSeedFromBlankTemplate — bootstrapping produces 2 collections, 0 items.

* fix: address codex review for blank template (IDEA-1479)

- CreateWorkspaceModal: remove hard-coded 'blank' picker entry that
  silently fell through to collections.Defaults(). The API-driven blank
  template (under the Custom category) is now the canonical surface.
- Dashboard: gate '+ New Task' button on tasks collection existence so
  blank workspaces don't render a button that targets a missing
  collection.
- OnboardingChecklist: accept collectionSlugs prop and filter steps
  whose target collection (plans/tasks/docs) is absent. Conventions
  step remains unconditional since the conventions collection ships
  with every template, including blank. Empty-steps guard added to
  progressPct to avoid NaN.
- web/src/lib/utils/templates.ts: add 'custom' -> 'Custom' to mirror
  the Go CategoryOrder + categoryLabels updates.
- cmd/pad/templates_picker_test.go: extend the visible-template
  assertion list to include 'blank' and assert the Custom category
  header renders.

* fix(store): gate SeedDefaultCollections on zero-collection workspaces (IDEA-1479)

The server's startup auto-upgrade hook (cmd/pad/main.go) called
SeedDefaultCollections against every workspace at boot. That hook
dates to the initial release — long before workspace templates
existed — and was written as a backfill for workspaces created
before tasks/ideas/plans/docs landed in Defaults().

Post-templates, the hook unconditionally re-materialized the
Software-template collections into any workspace missing them —
including blank-template workspaces (IDEA-1479), which ship only
Conventions + Playbooks by design. Result: every restart silently
regrew the ghost user-facing collections the blank template was
explicitly built to avoid.

Fix: SeedDefaultCollections now returns nil immediately when the
workspace has any existing collection (system or user-facing). The
rescue path still triggers for genuinely-empty workspaces, preserving
the original backfill intent.

Tests:
- TestBlankWorkspaceSurvivesSeedDefaultCollections — blank workspace
  remains 2 collections after auto-upgrade (and after a second pass).
- TestEmptyWorkspaceStillGetsDefaults — zero-collection workspace
  still gets the full Software default set.

* refactor(server): remove SeedDefaultCollections auto-upgrade at startup (IDEA-1479)

The startup auto-upgrade hook in cmd/pad/main.go dated to the initial
release, predating workspace templates entirely. Its original intent
was per-collection backfill — workspaces created before a new entry
landed in Defaults() would acquire it on next boot. Post-templates,
that semantic is incompatible with templates that legitimately
diverge from Defaults() (e.g. `blank`, which ships only Conventions
+ Playbooks by design).

Round-2 of the IDEA-1479 review attempted to keep the hook by adding
a "zero collections" guard, but Dave (after codex round 3) decided
the cleanest fix is removing the hook entirely. The codebase has
proper migration infrastructure now; any future "add a default
collection" work should land as an explicit migration where the
author chooses which workspaces to backfill.

SeedDefaultCollections itself is preserved (with the round-2 guard)
as a building block for any future explicit rescue command or
migration. Its doc comment is updated to note it's no longer
auto-invoked at startup. The round-2 regression tests
(TestBlankWorkspaceSurvivesSeedDefaultCollections,
TestEmptyWorkspaceStillGetsDefaults) still apply and pass unchanged.

* fix(store): rescue gate uses COUNT(*), not ListCollectionsMinimal (IDEA-1479)

Postgres CI on PR #560 caught a regression introduced in commit 3e71fe8:
SeedDefaultCollections's zero-collection guard called
ListCollectionsMinimal, whose SELECT uses COALESCE(settings, '') against
a JSONB column. Postgres parses the '' literal as JSON at plan time
and fails with SQLSTATE 22P02 (invalid input syntax for type json),
breaking the rescue gate and ~12 cascade test fixtures that depend on
the seeder succeeding.

The gate only needs to know whether any collection exists, not their
schema or settings. Switch to a direct COUNT(*) on the collections
table: portable across both drivers, cheaper than the minimal lister,
and avoids the broken JSON COALESCE path entirely.

Verified locally against both drivers:
  - SQLite (default): go test ./... — all PASS
  - Postgres (make test-pg infra):
    PAD_TEST_POSTGRES_URL=... go test ./... — all PASS, including
    the three direct failures (TestBlankWorkspaceSurvives…,
    TestEmptyWorkspaceStillGetsDefaults, TestSeedDefaultCollections)
    and the cascade FTS/search fixtures.

Note: ListCollectionsMinimal's COALESCE(settings, '') expression
appears to also affect production callers (handlers_dashboard,
handlers_items) on Postgres, but fixing that is out of scope for
this PR — those paths have their own tests that aren't failing in CI.
Flagged for separate follow-up.
2026-05-15 14:46:26 -04:00