Commit Graph

355 Commits

Author SHA1 Message Date
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
xarmian d3bd1958c5 feat(web): source_url ghost-field + Refresh from source affordance (TASK-1474) (#558)
* feat(web): source_url ghost-field + Refresh from source affordance (TASK-1474)

Final slice of PLAN-1467 — wires the editor's Insert-from-URL modal
to a source_url + imported_at ghost-field stamp and adds a refresh
affordance.

Editor.svelte:
- New onImportInserted prop. Forwarded to ImportFromUrlModal's
  onInserted so the host page learns when content was spliced in.

Item editor page:
- handleImportInserted(meta): only stamps when (a) item had no
  prior content AND (b) source_url is not already set, matching
  PLAN-1467's design rule. Stamping calls api.items.update with
  {fields: JSON.stringify({...fields, source_url, imported_at})}.
  source_url + imported_at are orphan keys — internal/items/validate.go
  only iterates declared schema fields, so unknown keys round-trip
  through PATCH without migration.
- refreshFromSource(): a small button beneath the title, visible
  only when fields.source_url is set and the user has write access.
  Confirms with a window.confirm warning (diff-preview deferred per
  PLAN risks section; Yjs op-log provides recoverable history), re-
  fetches via api.importURL, replaces editor content via
  selectAll().deleteSelection().insertContent(html), and bumps
  imported_at. View-only users see a non-interactive chip that
  shows the import provenance without the refresh action.

Both Editor mounts in the page (read-only and collab-editable
branches) pass onImportInserted={handleImportInserted}.

* fix(web): hide Refresh button in raw-markdown mode per Codex review (round 1)

P2: In raw-markdown mode the rich Editor is unmounted and replaced
by RawMarkdownEditor, but the parent retains a stale Tiptap editor
instance from the previous mount. Refresh-from-source drives
content replacement through that instance, so clicking it in raw
mode either failed silently or updated an off-screen editor while
the visible raw textarea stayed stale.

Fix: gate the interactive Refresh button on `canEdit && !rawMode`.
Read-only users AND raw-mode users now see the non-interactive
provenance chip — they can still discover the import history but
can't trigger a refresh from the inappropriate context. Switching
back to rich mode re-enables the button.

* fix(web): capture item identity across refresh await per Codex review (round 2)

P1: If the user clicked Refresh from source and navigated to a
different item before api.importURL() returned, the continuation
would replace the NEW item's editor content with the OLD item's
markdown AND stamp the OLD source URL onto the NEW item via
stampSourceUrl. Both surfaces awaited the fetch without snapshotting
the item / editor at call time.

Fix in two places:

- refreshFromSource: capture `targetItem = item` and
  `targetEditor = editorInstance` before any awaits; after the
  importURL await, bail if the live item.id no longer matches OR
  the editor instance was swapped (item navigation re-mounts the
  Editor with a new instance). Toast and spinner-clear are also
  gated on the identity match so the user who navigated away sees
  the destination item's UI, not stale feedback.

- stampSourceUrl: capture `targetItem` + `targetWs` before the
  PATCH and gate the assignment to `item` on identity. Also gates
  the failure toast so a stamp on the wrong workspace doesn't
  surface an "imported, but source_url not saved" toast on an
  unrelated item.

* fix(web): always clear `refreshing` in finally per Codex review (round 3)

P2: The previous identity-guard fix only cleared `refreshing = false`
when the live item still matched targetItem. Since the route
component is reused across item navigation and loadData() doesn't
reset `refreshing`, navigating away during an in-flight refresh
left `refreshing = true` persisted on the page-level state. Opening
any other item with a source_url showed a stuck "Refreshing…"
label and a permanently-disabled refresh button.

Fix: clear `refreshing` unconditionally in the finally. Per-item
visual feedback is only meaningful while the user stays on the
originating item; a navigation already signals "user moved on", so
the spinner state shouldn't persist past it.

* fix(web): use editor.isEmpty (live) instead of item.content (stale) for source_url stamp gate per Codex review (round 4)

P2: The "stamp source_url only when item had no prior content"
check read from `item.content`, which is the DATABASE snapshot —
under collab the editor's authoritative state lives in the Y.Doc
and isn't flushed to item.content until the debounced save fires.
A user could type into a newly blank item, open Insert from URL
before the autosave landed, click Insert, and the page would mark
the (already mixed) document as source-backed and enable the
destructive "Refresh from source" affordance over their typing.

Fix:
- ImportFromUrlModal: capture `editor.isEmpty` BEFORE insertContent
  runs, pass it via a new `InsertContext { wasEmpty: boolean }`
  argument on the `onInserted` callback. Reading isEmpty post-
  insert would always be false because we just added content.
- Editor.svelte: update the onImportInserted prop signature to
  forward the InsertContext.
- Page handleImportInserted: use ctx.wasEmpty instead of checking
  item.content. The previously-empty + not-already-stamped rule
  is preserved; only the source of "was empty" changes.

Editor.isEmpty consults the live ProseMirror doc, which under
collab reflects the Y.Doc state — so this is correct in both
single-user and collab modes.

* fix(web): namespace ghost-fields under pad_ prefix + narrow stamp race per Codex review (round 5)

Two findings addressed:

P2 #2: source_url collision with collection schema fields. Renamed
the ghost-field keys to `pad_source_url` and `pad_imported_at` so
they cannot collide with a user-defined `source_url` field on the
collection schema. Every read site (handleImportInserted's already-
stamped check, refreshFromSource, the chip's render gate + title,
the page title-row block) now reads from the prefixed keys.

P2 #1: race between concurrent field PATCHes. The `updateField` and
`stampSourceUrl` paths both PATCH the full `fields` JSON blob, so
a user field edit landing concurrently with our stamp would silently
overwrite one of the two changes. Cannot be fully fixed without a
server-side partial-fields update (a bigger refactor — tracked in
IDEA-1480). Mitigation here:

  - stampSourceUrl now re-fetches the item with api.items.get just
    before the PATCH and merges its two keys onto the freshest
    server snapshot. This narrows the window from "between read and
    PATCH-land" to "between fetch and PATCH-land" (typically <100 ms).
  - In-code comment cites IDEA-1480 so future readers know the
    inherent race exists and where to track the system-wide fix.

The existing project-wide updateField path has the same race
inherent to the bulk-PATCH design; it'll be closed by IDEA-1480
when the partial-update API lands.

* fix(web): reserve pad_ field-key prefix to prevent user-defined collision per Codex review (round 6)

P2: The pad_source_url / pad_imported_at orphan keys introduced in
round 5 are still user-definable in collection schemas. A field
labelled "Pad Source URL" auto-generates pad_source_url through
slugifyKey, shadowing the import-provenance metadata. Once
shadowed, the destructive "Refresh from source" chip would render
for ordinary user data and stampSourceUrl would overwrite the
user's field on import.

Fix: extend the UI-level field-key validator in
field-editor-types.ts to reject any key starting with the
RESERVED_FIELD_KEY_PREFIX = "pad_". The two known reserved keys
(pad_source_url, pad_imported_at) are also enumerated explicitly
in RESERVED_FIELD_KEYS so the failure message points to them by
name when slugifyKey happens to produce one. Future Pad-managed
orphan keys can land under the same prefix without retroactively
breaking existing collections.
2026-05-15 02:15:47 -04:00
xarmian 3a2ee6a45d feat(web): Insert from URL — TipTap toolbar button + modal (TASK-1473) (#557)
* feat(web): Insert from URL — TipTap toolbar button + modal (TASK-1473)

Wires the editor to POST /api/v1/import/url from TASK-1472.

Pieces:
- ImportURLResponse type + api.importURL() in lib/api/client.ts.
- ImportFromUrlModal.svelte — focus-on-open URL input, fetch button,
  preview pane with detected-type tag (OpenAPI / Generic) + title +
  source_url, Insert / Cancel footer. ESC and backdrop click close.
  Insert converts markdown → HTML via the project's existing `marked`
  renderer (same shape the editor uses for setContent on load), then
  insertContent(html) splices at the cursor.
- EditorToolbar.svelte — new 🌐 button in the blocks group opens the
  modal. Optional onImportInserted callback bubbles the response
  metadata so the parent (the item editor page in TASK-1474) can
  stamp source_url / imported_at into the item's fields.

Validation: light client-side URL parse + scheme check before hitting
the server. The server's canonical SSRF guard is the authority.

Toast feedback on successful insert via toastStore.show('...', 'success').

Parent: PLAN-1467.

* fix(web): wire ImportFromUrl into Editor's slash menu + race guard per Codex review (round 1)

P1: EditorToolbar.svelte is unused legacy — the live editor mounts
Editor.svelte directly with a slash-command UI. The previous diff
added a toolbar button no user could reach. Now:

- Revert EditorToolbar.svelte to its pre-PR state.
- Add `importUrl` block type to block-types.ts (insertOnly so it
  appears in the slash menu but not in the "Turn into" menu).
- Editor.svelte's execSlash handles the new case by setting
  `importUrlModalOpen = true`; the modal is mounted at the bottom
  of the editor template. The slash command surfaces via type
  "/url", "/fetch", "/web", "/openapi", "/import", or "/page".

P2: closing or re-fetching during an in-flight request previously
let a stale response land on a fresh modal session. Now a monotonic
`requestGen` counter is bumped on (a) every new fetch start, (b)
every cancel, and (c) every reopen via the open effect. handleFetch
captures its generation before await and drops both the response
and the error if requestGen has advanced past it.
2026-05-15 00:53:00 -04:00
xarmian b1fcedd5b5 fix(editor): match slash menu against id + keywords (BUG-1419) (#551)
Typing `/h2` (or `/ul`, `/hr`, `/todo`, etc.) in the tiptap editor
auto-closed the slash menu because the filter only matched on `label`
and `description`. "Heading 2".includes("h2") is false — the space
between "Heading" and "2" breaks the substring match — and zero
matches triggers closeSlash(), so the picker vanished as soon as the
user typed the second character.

Add optional `keywords?: string[]` to BlockType and populate common
abbreviations per block (h1/h2/h3, ul/ol, todo/checkbox, hr/rule,
quote/bq, code, html, tbl, etc.). Extend getFilteredSlash() to join
label + description + id + keywords into a single lowercased haystack
and substring-match the query against it.

Pure UI filter change — Y.Doc / ProseMirror shape unchanged, no
SCHEMA_VERSION bump. Turn-into menu unaffected (no filter there).
2026-05-14 22:22:35 -04:00
xarmian 38aa872864 fix(fields,activity): debounce typed-input field saves + collapse same-field activity runs (BUG-1466) (#549)
* fix(web): debounce typed-input field saves to stop per-keystroke activity rows (BUG-1466)

Text / number / URL fields in FieldEditor wired oninput directly to
onchange, so every keystroke became an item PATCH and an activity row.
Typing `ui/editor/tiptap` into a `component` field produced a 30-step
keystroke chain in the audit metadata (visible on BUG-1419's timeline).

Wrap the typed-input branches in a 500ms idle debounce, flush on blur
so tabbing away commits immediately, and flush on unmount so navigation
never drops a pending value. Discrete inputs (select / date / checkbox,
number ±1 buttons) keep firing on the user action — they aren't typing.
Mirrors the markdown content debounce pattern in the detail page.

* fix(activity): collapse same-field runs in merged changes metadata + unify diff separator (BUG-1466)

Follow-up to the web-side typing debounce. Two related changes:

1) collapseChanges() walks the merged "; "-delimited changes string and
   collapses runs of consecutive same-field entries into a single
   "field: first-old → last-new". Drops net no-ops (typed then backspaced).
   When the web-side debounce in FieldEditor still produces multiple
   PATCHes within the 5-minute coalesce window — or for older rows that
   pre-date the debounce — the timeline now reads as one transition
   instead of a chain. Run-based (not global) collapse so interleaved
   edits on different fields keep their chronology.

2) diffFields now joins entries with "; " instead of ", " so the joiner
   is consistent with mergeActivityMeta and TimelineActivityCard.svelte's
   split delimiter. Multi-field PATCHes previously rendered as a single
   unparseable blob in the web timeline because the parser only split on
   ";" — fixed as a side-effect.

Adds TestCollapseChanges (10 cases including the BUG-1419 repro) and
TestMergeActivityMeta_CollapsesSameFieldRun. Updates the existing
TestDiffFieldsPrimitives expectation to match the new joiner.

* fix(fields,activity): two follow-ups per Codex review (round 1)

[P1] FieldEditor.svelte::handleNumberStep
  The ±1 buttons computed `(Number(value) || 0) + delta` AFTER calling
  flushPendingSave(). But `value` is the parent prop — flushing fires
  onchange asynchronously, so at the moment of the step computation
  the prop still holds the pre-typed value. Typing 10 over 5 and
  clicking + would flush 10 then send 6, overwriting the typed value.
  Compute `base` from `pendingValue` (if hasPending) BEFORE clearing
  the timer state, then send `base + delta` in one onchange call.

[P2] activities.go::collapseChanges
  The drop-net-no-op step removed entries where `from == to`. But
  diffFields intentionally emits same-display entries for
  same-cardinality structured-field replacements like
  `implementation_notes: (1 note) → (1 note)` (see
  TestDiffFieldsSameCardinalityArrayChangeStillReported) — the labels
  match because formatChangeValue summarizes by count, not content,
  but the underlying data did change. Dropping them silently hides
  real updates from the activity feed.
  Track `mergedCount` per entry: increment when collapsing a run,
  initialize to 1 on parse. Only drop when `mergedCount > 1 && from == to`
  — i.e. only when the no-op resulted from collapsing multiple input
  segments (the typed-then-backspaced case).

Tests: 2 new TestCollapseChanges cases (single structured-field
preserved, interleaved structured-field around a typed run).

* fix(fields,activity): two follow-ups per Codex review (round 2)

[P1] FieldEditor.svelte number stepper focus race
  The number ±1 buttons race with the input's onblur handler: blur
  fires before click in the natural focus-transfer flow, so
  flushPendingSave clears hasPending → handleNumberStep reads stale
  `value` from the parent prop → typing 25 over 10 and clicking +
  sends 25 then 11, losing the typed value.
  Add onmousedown={preventDefault} on both ±1 buttons. Mousedown
  precedes blur, and preventDefault on mousedown suppresses the
  natural focus transfer — the input keeps focus through the click,
  so hasPending survives until handleNumberStep reads it.

[P2] collapseChanges still drops repeated same-display structured runs
  Round 1's mergedCount>1 rule still dropped runs like
  `implementation_notes: (1 note) → (1 note); implementation_notes: (1 note) → (1 note)`
  — two real updates whose display strings happen to match because
  formatChangeValue summarises array-valued fields by count. Each
  PATCH represented a different underlying note (diffFields uses
  reflect.DeepEqual to detect that), but the merged display showed
  no transition.
  Track `hadTransition` per entry: true iff the run had a display-
  level transition (initial from != to, or a subsequent entry's `to`
  differed from the anchored from). Drop only when
  mergedCount > 1 && from == to && hadTransition — i.e. only true
  net-cancellations (typed-then-backspaced). Same-display structured
  repeats stay; real `foo → bar → foo` swings still drop.

Tests: 2 new TestCollapseChanges cases (repeated same-display preserved,
real foo→bar→foo swing still dropped).

* fix(fields,activity): two follow-ups per Codex review (round 3)

[P1] FieldEditor cross-item leak via debounce timer
  When the parent reuses a FieldEditor instance across an item swap
  (same schema, same field.key, different item — common when
  navigating between items in the same collection), the parent's
  `updateField` closure reads `item.id` at CALL time. A pending
  timer set while item A was active would fire after item B mounted,
  patching B with A's typed value.
  Two-pronged fix in FieldEditor:
  - Add a $effect that tracks the `value` prop and drops any pending
    save the moment the parent re-props us. The user actively typed
    for a now-stale context; aborting is safer than silently writing
    to the new context. Also covers external collab/SSE rebases of
    the same field on the same item.
  - Switch the unmount cleanup from flush → drop. When the parent
    navigates to an item whose schema lacks this field, the
    FieldEditor unmounts AFTER the parent's `item` has already been
    replaced, so a final onchange call would route through
    updateField → wrong item. Blur is the supported commit gesture
    (clicking elsewhere within the page, ±1 buttons, tab-out — all
    flush eagerly); unmount-without-blur is treated as "user
    abandoned the edit."

[P2] collapseChanges still dropped structured count-return swings
  Round 2's hadTransition rule still dropped a run like
  `implementation_notes: (1 note) → (2 notes); implementation_notes: (2 notes) → (1 note)`
  — the user added a note then removed the original, ending with a
  different single note. The merged display reads `(1 note) → (1 note)`
  with hadTransition=true, indistinguishable from a typed-then-
  backspaced cancellation. But formatChangeValue summaries are
  LOSSY: same display label can wrap entirely different raw values.
  Add a `hasLossySummary` flag per entry — true iff either `from`
  or `to` matches the `(text)` format formatChangeValue emits for
  structured fields. Propagate stickily across the run (any lossy
  entry pins the whole run). Skip the drop step when the run
  carries lossy summaries — we can't recover the raw delta from the
  merged string, so we preserve the entry rather than risk hiding
  a real change.

Tests: 2 new TestCollapseChanges cases (count-return swing
preserved, lossy-on-one-side still pins the run).

* fix(fields): demote hasPending from \$state to plain let — \$effect was cancelling every keystroke (round 4 [P1])

Critical regression in round 3: the value-track \$effect read
\`hasPending\` inside its body, which Svelte 5 promotes to a reactive
dependency. scheduleSave() setting \`hasPending = true\` retriggered
the same \$effect, whose body cleared the typing timer + pendingValue
+ hasPending before the debounce could fire. Net effect: typing into
any text / number / URL field was silently dropped — onchange never
ran, the field never saved.

hasPending is only read from imperative handlers (scheduleSave,
flushPendingSave, handleNumberStep, the value-track \$effect, the
unmount cleanup) — never from a template or other reactive context.
Demoting it to a plain \`let\` removes the unwanted subscription while
preserving the round-3 behaviour: external value-prop changes still
trigger the \$effect (it tracks \`value\`), the body reads hasPending
imperatively to decide whether to clear pending state.

No tests added — this is a Svelte reactivity edge case that can't
be unit-tested without a DOM. svelte-autofixer's pre-existing
"variable assigned inside \$effect" suggestion previously flagged the
hasPending mutation; that signal is gone now.

Per Codex review round 4.
2026-05-14 22:10:12 -04:00
xarmian d7b99de2fb fix(web): await workspace items before Y.Doc seed so wiki-links don't bake in as text (BUG-1461) (#548)
The slug page fired collectionStore.loadItems fire-and-forget, racing the
Y.Doc seed effect. When the seed ran with an empty items array it fell
back to raw markdown, baking literal [[X]] text into the Y.Doc. The
seed's fragment.length > 0 gate made the corruption permanent — the
seed never re-fires for that item.

Fold loadItems into loadData's Promise.all and await it before `item`
is set. Gate the call on a new workspace-scoped freshness check
(itemsAreFreshFor) rather than items.length, so a stale items array
left over from a previous workspace doesn't satisfy the guard.

itemsWorkspace is stamped only on full-workspace loads; collection-
scoped loads invalidate it so callers needing the full workspace
correctly re-fetch.

Existing items whose Y.Doc was already baked stay broken until edited
and re-saved (markdownToWikiLinks rewrites them on the save round-trip).

Verified by Codex second-opinion review.
2026-05-14 21:11:52 -04:00
xarmian 9fb6ac006b fix(web): scroll restoration via SvelteKit snapshot API (BUG-1425) (#545)
Replaces TASK-755's bespoke listing-only scroll-restoration code
with a reusable createScrollRestoration helper built on
SvelteKit's snapshot API, applied across every workspace top-
level page (item detail, collection listing, workspace home,
activity, starred, library, conventions, playbooks list/detail,
roles).

## The bug

On any workspace page, navigating away and back left the user
near the top of the page even though they had scrolled down. The
listing's old TASK-755 workaround also didn't work in practice
once the layout's .main-content overflow-y:auto landed (it had
been targeting window.scrollY which is permanently 0).

## The fix

new `web/src/lib/scroll/restore.svelte.ts`:

  createScrollRestoration({ ready, persistKey? }) returns
  { snapshot } that the page re-exports as SvelteKit's snapshot
  contract.

  Layered restoration strategy:
  1. SvelteKit snapshot (per-history-entry sessionStorage) for
     back/forward.
  2. localStorage fallback for cross-tab / workspace-switcher
     goto() (no popstate) restoration, re-fires per persistKey
     change.
  3. Per-key restoredKey one-shot so routes that reuse a
     component instance across URLs get fresh restoration on
     each new entry.
  4. snapshotKey tracks the SvelteKit-claimed key so LS
     fallback yields to a popstate snapshot.restore that beats
     the effect.
  5. ready() gate: caller-provided predicate must return true
     before we attempt to scroll, with the contract that for
     routes which reload on URL change the caller verifies
     content-vs-URL identity (e.g. item.slug === itemSlug ||
     issue-id === itemSlug). itemUrlId() prefers refs over
     slugs so the issue-id branch is the dominant URL shape.
  6. Retry loop with scrollHeight-stability gate (~250ms) and
     a 2s budget. Per-frame re-scroll handles Tiptap rendering
     content across many frames and async property-card fields.
  7. User-input bail via wheel/touchmove/keydown listeners,
     NOT a scrollY diff. The browser's default
     overflow-anchor: auto adjusts scrollTop when content layout
     shifts; that browser-driven change isn't user input and
     mustn't trigger the bail.
  8. Scroll target is .main-content (the app's actual overflow
     container set by the root layout), NOT window. Window's
     scrollY/scrollTo is a no-op for this app's chrome.

## Per-page integration

Each workspace page calls createScrollRestoration() with a
ready() predicate appropriate to its loading shape and a
pathname-keyed persistKey. The collection listing's persistKey
deliberately excludes ?search and showArchived (filter toggles
call goto({replaceState}) and would otherwise jump scroll
mid-interaction).

## Code path summary

- web/src/lib/scroll/restore.svelte.ts — new helper (~460 lines
  with extensive design notes).
- workspace +page.svelte and 9 other route files — thin call
  sites adding ~10-30 lines each.
- [collection]/+page.svelte — removes ~140 lines of TASK-755's
  bespoke localStorage + double-RAF code in favor of the
  helper.

Net: +637 / -149.

## Verification

- make check passes (golangci-lint, go test, npm run build).
- svelte-check 0 errors.
- Manual repro of the canonical scenario (item → wiki-link
  child → back) confirmed working including the
  multi-section ChildItems layout that exposed the
  scroll-anchoring bail bug.

## Development trail (squashed from 11 commits)

This commit is the final state of an unusually long iteration:
Codex was consulted 5 times in a review loop and produced a
sequence of correct-but-insufficient fixes (self-cancelling
effect, lifetime-scoped guard, stale-content race, slug/ref
match, snapshot/LS race, per-key reset) all of which were
operating on the wrong measurement: window.scrollY. Once
diagnostic console.logs were added (round 9) the actual problem
fell out in two rounds — wrong scroll target, then wrong bail
signal. Lesson: when behaviour doesn't match logic, instrument
before iterating.
2026-05-14 13:22:54 -04:00
xarmian 2a5b0113d8 feat(web): surface invocation_slug + arg count on library playbook cards (TASK-1399) (#528)
* feat(web): surface invocation_slug + arg count on library playbook cards (TASK-1399)

Adds the PLAN-1377 invocation surface to the playbook library UI so
users can see at a glance what makes an invokable playbook different
from a passive checklist.

- web/src/lib/types/index.ts: add LibraryPlaybookArgument type;
  extend LibraryPlaybook with optional invocation_slug + arguments
  matching the Go struct shape (T1).
- web/src/lib/api/client.ts: activatePlaybook payload now forwards
  invocation_slug + arguments into the seeded item's fields JSON
  when set, mirroring ShipPlaybook() and the CLI/MCP activate paths
  fixed in T1.
- web/src/routes/[username]/[workspace]/library/+page.svelte:
  - Playbook cards render `/pad <slug>` chip (mono, green) when
    invocation_slug is set.
  - Render `N arg{s}` badge (amber) when arguments has entries.
  - Both badges are conditional, so legacy library entries that omit
    them render unchanged.

Verified: `npm run build` clean. Cards for trigger-only playbooks
look unchanged; future invokable entries (T3-T5) will pick up the
new chips automatically.

Parent: PLAN-1397.

* fix(library): use template-literal expression for slug-chip title per Codex review (round 1)

Round 1 P1: the `title="Invoke via \`/pad {slug}\`"` form on line 218
tripped Svelte's parser because the literal backticks inside the
quoted attribute value were interpreted as template-literal
delimiters mid-attribute. `svelte-check` reported 9 errors on the
line; vite build accepted it but the type-check did not.

Fix is the form Codex suggested: pass the value as a JS expression
with a real template literal:

  title={`Invoke via /pad ${playbook.invocation_slug}`}

svelte-check now reports 0 errors on the file. The remaining warnings
in the output are pre-existing in unrelated files (NestedChildren,
ChildItems, roles, admin) and are out of scope for this PR.
2026-05-13 00:34:09 -04:00
xarmian 1d3b1a5355 feat(web): first-class playbook editor — slug validation, args builder, test invocation (TASK-1384) (#524)
* feat(web): first-class playbook editor — slug validation, args builder, test invocation (TASK-1384)

Builds the first-class playbook editing experience for PLAN-1377's
invocation model. New surface area:

- arguments.ts — shared parser/generator for the playbook body's
  ## Arguments section, plus the canonical invocation_slug regex,
  the skeleton-template body inserted on new, and a buildTestInvocation
  helper that produces the three command renderings (Claude Code, CLI,
  pad_playbook MCP JSON) from a slug + sample inputs. The structured
  arguments JSON field is canonical; the markdown section round-trips.

- PlaybookFormFields.svelte — reusable Svelte 5 component with the
  structured slug input (kebab-case validation + workspace-scoped
  uniqueness check, debounced 300ms), trigger selector with
  Other-(custom) escape hatch, scope + status selectors driven by the
  collection schema, an arguments builder (add/remove/edit each
  PlaybookArgument card, with type-specific options for enums), and
  the test-invocation helper. Args ↔ body section is two-way bound via
  signature-key tracking to avoid reactive loops.

- playbooks/[slug]/+page.svelte — dedicated edit page that loads an
  existing playbook, hosts the title input + Save/Cancel actions, and
  splits the layout (form fields | body textarea). Builds the canonical
  fields object on save: arguments stored as a JSON value, empty
  invocation_slug omitted entirely so the optional column stays clean.

- playbooks/+page.svelte (list) — pre-fills the new-form textarea with
  PLAYBOOK_SKELETON_BODY when opened, embeds PlaybookFormFields beside
  the body textarea so every new playbook gets the same affordances,
  and emits the canonical fields shape on create.

Acceptance:
- Creating from "+ New" shows the skeleton template
- Non-kebab-case slug → inline error
- Duplicate slug → debounced inline error
- Arguments builder mutations update the body's ## Arguments section
- Editing the markdown section reflects back in the structured form
- Test invocation shows /pad ship PLAN-609 stop-after-each merge-strategy=rebase

Parent: TASK-1384 / PLAN-1377.

* fix(web): collection guard + preserve custom triggers + carry duplicate args per Codex review (round 1)

Codex round 1 findings:

1. [P2] Edit page used cross-collection api.items.get; /playbooks/TASK-1 could
   load a task and Save would rewrite its fields as a playbook. Added a
   collection_slug guard — if the loaded item isn't a playbook, show a toast
   ('Not a playbook — TASK-1 lives in tasks') and refuse to render the editor.

2. [P2] Snap effects in the list page (newTrigger/newScope) were forcing
   the form's current value into the schema list. When a user typed a
   custom trigger via PlaybookFormFields' 'Other…' mode, the snap silently
   replaced it with the first schema option. Gated both snaps on
   !showNewForm so they only fire while the form is closed (initial
   schema-vs-default reconciliation), leaving user edits untouched.

3. [P3] duplicatePlaybook dropped the arguments contract. A copy of an
   argumented playbook silently lost its arg spec while the body still
   described them. Carry forward fields.arguments on duplicate.
   invocation_slug is intentionally still dropped — a duplicate would
   clash on the unique index — but arguments are non-unique and safe.

Parent: TASK-1384 / PLAN-1377.

* fix(web): hide create-form status selector overridden by submit buttons per Codex review (round 2)

Codex round 2 finding:

[P2] PlaybookFormFields.status was wired into the new-form but the
'Create as Draft' / 'Create as Active' submit buttons pass their status
literal directly to createPlaybook(status), silently overriding any
status the user selected (deprecated, especially).

Added a hideStatus prop to PlaybookFormFields, defaulted to false (edit
page keeps the selector). Pass hideStatus={true} from the create form
where the buttons already own status. Edit-page UX unchanged.

Parent: TASK-1384 / PLAN-1377.

* fix(web): preserve unknown fields on playbook save per Codex review (round 3)

Codex round 3 finding:

[P2] save() rebuilt the fields object from scratch (status/trigger/scope/
arguments/invocation_slug), so api.items.update — which replaces the
whole fields JSON blob — would silently drop any custom workspace
fields or future metadata the form doesn't render. Fixed by seeding
the saved fieldsObj from parseFields(item) so unknown keys survive
the round-trip. Empty invocation_slug now explicitly deletes the
key rather than persisting an empty string that would still hit the
unique index.

Parent: TASK-1384 / PLAN-1377.

* fix(web): clear stale item on load + coerce typed default values per Codex review (round 4)

Codex round 4 findings:

[P2] loadItem catch path left the previously-loaded playbook editable
when a re-fetch under a new slug failed. Cleared item = null at the
start of every load and on the error path so a 404 renders 'Playbook
not found' instead of letting the user edit the stale item.

[P2] PlaybookFormFields' Default input always stored the value as a
string; flag/number defaults like 'true' or '5' were serialized as the
strings '"true"' / '"5"' into fields.arguments. The server passes
defaults opaquely, so agents got the wrong types when binding. Added
coerceDefaultForType in arguments.ts (mirrors the markdown parser's
coerceDefaultValue rules) and applied it from argumentsToJSON before
serialization.

Parent: TASK-1384 / PLAN-1377.
2026-05-12 21:01:39 -04:00
xarmian c38b3bf5cd feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378) (#517)
* feat(playbooks): add invocation_slug + arguments schema fields (TASK-1378)

Foundational change for PLAN-1377 — playbooks become first-class invokable
procedures. Two new optional fields land on the Playbooks collection
schema:

- `invocation_slug` (text, kebab-case, unique-per-workspace among non-null
  values): enables `/pad <slug>` direct invocation. Nullable so
  trigger-only playbooks (e.g. on-release checklists) don't need one.
- `arguments` (json, array of {name, type, required, default, description}):
  declares the playbook's argument contract; mirrors the body's
  `## Arguments` section in queryable form.

Plumbing pieces:

- `models.FieldDef` grows two general-purpose options — `Pattern` for
  regex validation and `UniqueScope` for collection-level uniqueness.
  Both are opt-in; existing schemas are unaffected.
- `items.ValidateFields` learns the `json` field type (accepts any
  JSON-decodable value) and applies `Pattern` to string-typed values.
- `handlers_items.checkUniqueFields` queries `Store.ListItems` to enforce
  `UniqueScope == "workspace_collection"` on create + update.
- Two migrations (SQLite 054, Postgres 033) JSON-patch the playbooks
  schema on existing workspaces so the new fields show up without a
  workspace re-init.
- TypeScript `FieldDef` mirrors the Go side.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 1 findings (TASK-1378)

P1 — EditCollectionModal now round-trips opaque pattern/unique_scope
metadata. EditableField carries the new keys; the load + save paths
preserve them so re-saving the playbooks collection from the UI doesn't
strip server-side validation rules the modal doesn't yet expose
dedicated controls for. fieldFromDef mirrors the change for templates.

P2 — checkUniqueFields' pre-write ListItems check is now backed by a
partial unique index (idx_items_invocation_slug_per_collection,
SQLite + Postgres) scoped to non-empty, non-deleted rows. The pre-check
still gives users a friendly error message in the common case; the
index closes the TOCTOU race between two concurrent writers. The
create-conflict error message is now generic enough to cover both the
slug constraint and the new invocation_slug index.

P2 — `json` field type now rejects raw strings, numbers, and bools. Only
objects, arrays, and null are accepted, so a generic web text input
can't silently corrupt a structured field by emitting "[]" instead of
an actual array. FieldEditor.svelte routes `json` fields to a
read-only summary in both readonly and edit modes; dedicated editors
(like TASK-1384's playbook editor that owns `arguments`) own the
structured form.

P3 — invocation_slug regex now requires a minimum of two characters
(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) in the Go const, the SQLite migration,
the Postgres migration, and the validate tests. Single-letter slugs
would shadow plausible NL tokens (e.g. `/pad a ...`) and the doc
comment already claimed the two-char floor; this aligns code with
intent.

Parent: PLAN-1377.

* fix(playbooks): address Codex review round 2 findings (TASK-1378)

P2.1 — checkUniqueFields no longer passes IncludeArchived=true. The
application-layer pre-check now matches the partial unique index's
`deleted_at IS NULL` predicate so a soft-deleted playbook releases its
slug back to the pool and reclaiming it succeeds instead of 409'ing.

P2.2 — handleUpdateItem now maps UNIQUE constraint / duplicate key
errors from UpdateItem to HTTP 409, mirroring the create path. A true
concurrent-update race that slips past checkUniqueFields and trips the
partial unique index used to surface as a misleading 500.

(Not addressed in this round: Codex's third finding — concern about the
partial unique index applying to "every collection" — is, on close
reading, not what the index does. `ON items(collection_id, json_extract(...))`
scopes uniqueness to the (collection_id, slug) pair, so two items in
different collections with the same `invocation_slug` value coexist
fine. The migration-failure risk is theoretical: `invocation_slug` is
a brand-new field key, so no pre-existing items can have it set, and
no migration-time duplicates can exist. If a future custom collection
adopts the same field name, opting into per-collection uniqueness is
exactly the intended semantic of FieldDef.UniqueScope.)

Parent: PLAN-1377.

* fix(playbooks): map restore-path UNIQUE violations to 409 (TASK-1378)

Codex round 3: restoring an archived playbook can hit the partial
unique index on invocation_slug if a replacement item already claimed
the slug. Map UNIQUE constraint / duplicate key errors from RestoreItem
to HTTP 409 with a targeted message, matching the create + update paths.

Parent: PLAN-1377.

* fix(playbooks): map collab-snapshot UNIQUE violations to 409 (TASK-1378)

Codex round 4: the collab-snapshot PATCH branch under
`s.collab.UnderItemLock` ran its own UpdateItem call and fell through
to writeInternalError on any non-stale-snapshot error. A concurrent
edit racing the invocation_slug partial unique index would surface as
500 instead of 409. Mirror the main UpdateItem error mapping.

Codex's other round-4 finding — the partial unique index applying to
"every collection" — is not addressed because the index IS already
collection-scoped: `ON items(collection_id, json_extract(fields,
'$.invocation_slug'))`. Two items in different collections with the
same slug coexist; only same-collection duplicates conflict. Migration
duplicates are impossible because `invocation_slug` is a brand-new
field key with no pre-existing items setting it. A custom collection
that later adopts the same field name opts into per-collection
uniqueness, matching the FieldDef.UniqueScope="workspace_collection"
semantic.

Parent: PLAN-1377.
2026-05-12 17:17:56 -04:00
xarmian 9cbb08a16c feat(web): wire ContentError + retry for HTTP fail, collab offline, stuck-connecting (TASK-1376) (#516)
* feat(web): wire ContentError + retry for HTTP fail, collab offline, stuck-connecting (TASK-1376)

Three failure modes now surface ContentError with a retry path:

1. HTTP load error (page-level): the {:else if error} branch
   replaces the literal `<div class="center-message">{error}</div>`
   with <ContentError onRetry={loadData}>. Users can recover without
   navigating away.

2. Collab offline: when the WS provider hits the OFFLINE_THRESHOLD
   (3 consecutive failed reconnects) and `state === 'offline'`, the
   editable {:else if ydoc} branch surfaces ContentError instead of
   the empty Y.Doc editor.

3. Stuck-connecting: a 10s timer-driven $effect sets
   staleConnecting=true if the provider sits in `connecting` without
   ever syncing. Same ContentError UI as offline. Timer is cleared
   on state change, hasEverSynced flip, or provider rebuild.

Retry path (retryCollabSync) mirrors the server-driven force_refresh
dance:

  1. Clear staleConnecting (state will reset naturally on rebuild).
  2. Refetch items.content so the lazy-seed (TASK-1261) on the new
     Y.Doc has canonical content.
  3. Bump forceRefreshNonce → the existing collab $effect tears down
     the dead provider, mints a new one. The TASK-1375 reset $effect
     handles `hasEverSynced=false` and `editorInstance=null` as part
     of that rebuild, so retry doesn't need to touch them directly.

Error gate is placed BEFORE the skeleton gate in the {:else if ydoc}
branch so stuck-connecting flips out of shimmer-forever and into a
clear error UI at the 10s mark.

CONVE-606: the stuck-connecting $effect has a single clean dependency
list (collabProvider + state + hasEverSynced); the latch is a pure
imperative flag flipped by a setTimeout, not derivable.

Parent: PLAN-1373. Resolves BUG-1372 (final piece).

* fix(web): preserve local edits on retry, reset staleConnecting per provider (TASK-1376 round 1)

Codex round 1 caught three correctness issues in the initial retry
wire-up; addressed all of them.

P1 — retryCollabSync was overwriting local edits.
The original (lifted from onForceRefresh) refetched items.content
before bumping forceRefreshNonce. In the server-driven
force_refresh case that's correct because the server is the source
of truth. In the retry case the LOCAL Y.Doc is the canonical view
(it may hold unflushed user typing from the offline/connecting
window); shoveling stale server content into \`item\` before the
cleanup's flushCollabNow ran risked the lazy-seed on the new Y.Doc
re-encoding the stale view, then the next flush PATCHing that back
over the user's just-persisted edits.

Fix: drop the refetch. The collab \$effect cleanup already calls
flushCollabNow on tear-down (lines ~727–729), preserving local
edits via PATCH BEFORE the new provider mints a fresh Y.Doc. The
new provider's WS replay reconciles against server state via the
op-log; if the cursor has been pruned the server sends a real
force_refresh which goes through onForceRefresh (which DOES
refetch — correctly).

P2 (first) — failed retry left staleConnecting=false with no
retry affordance. Gone naturally: retryCollabSync is now
synchronous with no failure path.

P2 (second) — staleConnecting was not reset when collabProvider
rebuilt. The early-return-on-null path skipped the false-reset,
so a stuck-connecting flag from a previous provider carried into
the new one, showing error UI immediately instead of granting
the fresh 10s grace.

Fix: unconditional \`staleConnecting = false\` at the top of the
effect (after the null guard). Only the 10s timer can flip it
back to true.

Codex round 1.

* fix(web): gate offline error UI on !hasEverSynced to protect local edits (TASK-1376 round 2)

Codex round 2: the fire-and-forget flushCollabNow in the collab
\$effect cleanup is racy — it kicks off a PATCH but doesn't await
runCollabFlush or update local item.content. The new provider's
lazy-seed reads item.content (stale relative to the local Y.Doc),
encodes it into a fresh op-log, then the next 5s flush PATCHes that
stale content back over the user's just-flushed edits.

Real fix: don't expose retry when there are local edits at risk.

The template's error gate now reads:

  (collabProvider?.state === 'offline' && !hasEverSynced) || staleConnecting

Both branches imply !hasEverSynced, so the current Y.Doc has never
received a sync and therefore cannot hold user edits. retryCollabSync
is safe in that universe — tearing down the provider can't lose
unflushed work.

For state === 'offline' WITH hasEverSynced=true (was synced, then
got disconnected), the editor stays mounted with its bound Y.Doc:

  - The corner badge (line ~1700) already signals offline via the
    four-state pending-sync indicator.
  - CollabProvider's reconnect loop keeps trying with exponential
    backoff (1s → 30s capped); auto-recovery is the path.
  - In-progress user edits remain bound to the live Y.Doc;
    nothing destroys them.
  - When the WS comes back, normal sync flow reconciles.

This is also a better UX than the prior "wipe editor, show error" —
a user mid-edit doesn't lose their working canvas when their wifi
hiccups.

Codex round 2.
2026-05-12 12:34:34 -04:00
xarmian 1af63e3a47 feat(web): wire ContentSkeleton into item detail loading + collab-sync (TASK-1375) (#515)
* feat(web): wire ContentSkeleton into item detail loading + collab-sync (TASK-1375)

Two gaps where the item detail page would show a blank body:

1. HTTP-load gap — `{#if loading}` rendered the literal string
   "Loading..." while loadData()'s GET was in flight. Now renders
   <ContentSkeleton variant="page" />.

2. Collab-sync gap — for editable items, the Editor mounted on
   `ydoc !== null` but content lives in the Y.Doc which starts
   empty until the WS replays the op-log. On slow/broken
   connections this looked indistinguishable from a genuinely
   empty item. The {:else if ydoc} branch now renders
   <ContentSkeleton variant="inline" /> while
   `collabProvider.state === 'connecting' && !hasEverSynced`.

The `hasEverSynced` latch (split into two $effects per CONVE-606
— route-change reset on item.id vs reactive-state-sync on
collabProvider.synced) ensures mid-session `reconnecting` does
NOT re-show the skeleton over already-rendered content. The
reset on item navigation handles SvelteKit's reuse of
+page.svelte across [slug] changes.

Error UI and retry (offline state, stuck-connecting timeout) is
TASK-1376's scope — this PR is skeleton-only.

Parent: PLAN-1373. Resolves part of BUG-1372.

* fix(web): reset hasEverSynced on provider change, not just item.id (TASK-1375 round 1)

Per Codex review: the original `void item?.id` reset dependency
missed two cases where the same item gets a fresh unsynced Y.Doc:

  1. Raw -> Rich mode toggle (collabKey derives from rawMode, so
     the collab $effect tears down + rebuilds the provider but
     item.id is unchanged).
  2. forceRefreshNonce bump (server force_refresh or a future
     TASK-1376 retryCollabSync), which rebuilds the provider
     against the same item.

In both cases hasEverSynced=true survived the rebuild, the
skeleton gate bypassed, and the editor mounted on the new
unsynced Y.Doc showing blank — re-opening the very pre-sync
empty window the skeleton was meant to close.

Fix: depend on `collabProvider` (the reactive variable, not its
fields). Any provider instance change — navigation, mode toggle,
nonce bump, cleanup-to-null — fires the reset. Strictly more
correct than item.id since it also covers same-item rebuilds.

Codex round 1.

* fix(web): null editorInstance on provider change (TASK-1375 round 2)

Per Codex review round 2: `editorInstance` is set by the Editor's
`onEditor` callback on mount but is NOT re-nulled on unmount. During
the connecting-skeleton window for a same-item provider rebuild
(rawMode toggle, force_refresh), the old <Editor> unmounts but
`editorInstance` still points at the previous (now-destroyed)
instance.

If an `applier_request` frame arrives on the new provider during
that window, `onApplierRequest` would call `setContent` on the
WRONG editor (or a destroyed one) instead of returning false and
letting the server fall back to a direct items.content write.

Fix: null `editorInstance` in the same reset $effect that resets
`hasEverSynced`. The new editor's onEditor callback re-populates
the reference once it mounts after the skeleton phase.

Codex round 2.
2026-05-12 11:58:33 -04:00
xarmian 307d6e7221 feat(web): add ContentSkeleton + ContentError primitives (TASK-1374) (#514)
Two reusable presentational components for the item-detail loading
and error UX work in PLAN-1373:

- ContentSkeleton: CSS-only shimmer placeholder with 'page' / 'inline'
  variants. Respects prefers-reduced-motion. Pure presentational, no
  Pad-state imports.
- ContentError: centered title + optional detail + Try again button.
  Mirrors EmptyState's visual language.

Both use Svelte 5 runes, design tokens, and a11y attributes
(role=status / role=alert, aria-hidden on decorative bars/icon).

No wiring yet — TASK-1375 and TASK-1376 consume these.

Parent: PLAN-1373.
2026-05-12 11:17:52 -04:00
xarmian 350e8ef576 feat(web): saved view defaults applied on collection-page entry (TASK-1366) (#512)
* feat(web): saved view defaults applied on collection-page entry (TASK-1366)

Phase 3d of the local-first read model (PLAN-1343 / DOC-1342): per
the design note's recommendation, persist the user's preferred saved
view per (workspace, collection) in localStorage and re-apply it on
mount. No schema change in v1; cross-device sync can come later as a
server-side `is_default` column on saved views.

Behavior
- localStorage key: `pad-default-view:<wsSlug>:<collSlug>` → view id.
- On collection-page mount, after `savedViews` loads, look up the
  default and call `applyViewConfig` automatically.
- URL-driven state wins: if the user arrived via a shared link with
  `?q=...` or `?status=...`, the default is skipped so the link's
  intent isn't hijacked.
- "Make default" / "Default ★" toggle next to the saved-views bar
  flips the persistence state for the currently active view. The
  active default also renders a small pin icon on its tab so users
  can see which view is current at a glance.
- `deleteView` clears a dangling localStorage pointer when the
  default view is the one being removed.
- localStorage failures (private mode, quota) degrade silently — the
  toggle still works for the session.

Out of scope (deferred to a future PR)
- Cross-device sync via a server-side `is_default` column.
- Sharing defaults across workspace members.
- Auto-save view config changes back to the underlying saved view.

Parent: PLAN-1343. Completes Phase 3 of DOC-1342.

* fix(web): gate default-view apply on metaLoading per Codex review (round 1)

Codex round 1 P1 #1: `loadCollection` flips `metaLoading=true` at
entry, assigns `savedViews` mid-flight, then calls
`loadUrlFilters()` synchronously near the end, and only flips
`metaLoading=false` in the `finally` block. My default-view
effect tracked `savedViews` and ran as soon as it changed — so
a shared link like `?q=foo` could land in the local state AFTER
the savedViews assignment but BEFORE `loadUrlFilters` populated
`searchQuery`. The effect saw an empty searchQuery, thought there
were no URL overrides, and applied the default — clobbering the
incoming URL.

Codex round 1 P1 #2: on client-side navigation across collections,
`defaultViewApplied` got reset by the route-change effect but
`savedViews` still held the PREVIOUS collection's views until the
new fetch resolved. The default-apply effect would run with the
wrong list, fail to find the new collection's default view id in
the stale list, and ERASE the localStorage pointer — wiping the
default on every cross-collection navigation.

Gate the effect on `!metaLoading`. `metaLoading=false` only
fires after BOTH the new `savedViews` is assigned AND
`loadUrlFilters()` has run, so both races are eliminated.

* fix(web): URL-override check reads page.url directly per Codex review (round 2)

Codex round 2 P1: `?view=board` URLs are explicit user intent but
my urlOverrides check only looked at `searchQuery` and
`activeFilters`, so view-only URLs would be overwritten by the
default-view apply.

Codex round 2 P2: `loadUrlFilters` doesn't clear absent params, so
parsed `searchQuery` / `activeFilters` can carry leftover values
from the previous route on cross-collection navigation. A clean
URL on the new route would then look "overridden" via stale
parsed state, and the default would be incorrectly skipped.

Both fixed by reading `page.url.searchParams.size` directly. The
collection page only writes user-driven params (view, q, field
filters), so any non-empty searchParams signals explicit intent.
2026-05-12 01:08:38 -04:00
xarmian 06c5e5cd2d feat(web): CommandPalette uses localSearch (TASK-1365) (#511)
* feat(web): CommandPalette uses localSearch (TASK-1365)

Phase 3c of the local-first read model (PLAN-1343 / DOC-1342): wire
the global CommandPalette / top-bar search to localSearch.

Behavior
- Default scope: search the current workspace's in-memory MiniSearch
  index synchronously on every keystroke. No network round-trip, no
  200ms debounce — sub-millisecond typing.
- "All workspaces" toggle: when on, also search every other workspace
  whose localIndex is `'ready'` (i.e. already hydrated this session).
  Results from every ready workspace are merged by score, ties broken
  by `updated_at DESC`. Toggle state persists to localStorage so it
  survives reloads. Hidden when only one workspace is ready (no
  point showing a no-op toggle).
- Cross-workspace navigation: each local result carries its source
  workspace slug + owner_username so `selectResult` navigates to the
  right route — `selectResult` falls back to `workspaceStore.current`
  for server hits.
- Server fallback paths:
   * `body:` / `content:` queries — local index doesn't hold the
     rich-text body, so server FTS is the only way to grep.
   * No ready workspaces yet (cold session) — falls through to
     `api.search` so the palette still works pre-bootstrap.
- Reactive: a single `$effect` watches `query`, `searchAllWorkspaces`,
  filter chips, every workspace's `localSearch.epoch`, and the
  current workspace's bootstrap state — so SSE-driven upserts and
  hot toggle flips re-rank without manual `doSearch()` calls. The
  `oninput={doSearch}` handler is removed; reactivity does the work.
- Drops `result-count`, `loadMore`, and facets on the local path —
  local results aren't paginated (everything's in RAM, capped at
  `PAGE_SIZE * 2` for display); facets are a server-only feature.

Parent: PLAN-1343.

* fix(web): hide Load more on local search path per Codex review (round 1)

Codex round 1 P2: `total` was set to the pre-slice
`filtered.length` while `results` was capped at `PAGE_SIZE * 2`.
That made the "Load more" affordance show when local matches
exceeded the cap — and clicking it would call `api.search`, which
injects single-workspace server-FTS rows into the local
(potentially cross-workspace) result set.

Set `total = results.length` on the local path so the affordance
stays hidden. Local results are all in RAM; if the result count
exceeds the display cap the right answer is a tighter query, not
a paginated server fetch.

* fix(web): current-workspace fallback + filter chip persistence per Codex review (round 2)

Codex round 2 P2 #1: with `searchAllWorkspaces` on, if the current
workspace was still bootstrapping but any OTHER workspace was ready,
`ready.length > 0` sent the search down the local-only path —
omitting current-workspace results entirely. The toggle is meant to
widen the search, never to replace the current workspace.

Add an explicit `currentReady` check: only take the local path
when the current workspace is ready. Otherwise fall through to the
server (which will return the current workspace's results too).

Codex round 2 P2 #2: filter chips were gated on `facets` (server
only). Switching from server → local with a filter active would
hide the chip but keep the filter applied. Add a fallback row that
shows the active chip(s) on the local path so they're visible and
clearable.

* fix(web): stale-response guard + status filter under-fill per Codex review (round 3)

Codex round 3 P2 #1: server search responses had no stale-response
guard. A request started while `currentReady` was false (or for a
`body:` query) could return after the local path had already
rendered and clobber local results — including reintroducing
`total > results.length` and the Load more button. Snapshot the
query + `searchAllWorkspaces` flag at dispatch; only apply the
response if both still match. The same guard protects the catch
and finally branches.

Codex round 3 P2 #2: local status filtering was applied AFTER the
per-workspace `localSearch.search(... limit: 20)` cap, so a status
chip could under-fill or empty the result set even when matching
items existed beyond the cap. Expand the per-workspace pull by 5x
when `filterStatus` is active so the post-fetch filter has
headroom. (The collection filter doesn't need this because it's
passed directly to `localSearch.search`, which filters inside
the index walk.)

* fix(web): full-scope stale-response guard per Codex review (round 4)

Codex round 4 P2: the R3 stale-response guard only snapshotted
`query` and `searchAllWorkspaces`. An in-flight server response
could still clobber newer local results after `currentReady`
flipped to true, or overwrite results after a filter chip changed
with the same query.

Snapshot the full dispatch scope (query, toggle, filter chips,
current workspace slug, currentReady-vs-body branch) at request
time and gate every `results = ...` site on `isSameDispatch()`.
Once the current workspace hydrates, a server response from a
`!currentReady` snapshot is no longer authoritative.

* fix(web): read live currentReady + guard loadMore per Codex review (round 5)

Codex round 5 P2 #1: my R4 `isSameDispatch` captured
`currentReady` at dispatch time, so the check stayed stale once
the index hydrated mid-request — the cold server response still
matched and clobbered the local results that the readiness effect
had just produced. Switch to reading live state via
`localIndex.bootstrapStateFor(snapshotWsSlug)` inside the guard;
captured `snapshotCurrentReady` is removed.

Codex round 5 P2 #2: `loadMore` only snapshotted query/filters. A
server-page request in flight could append rows after the scope
changed (current workspace hydrated, all-workspaces toggle flipped).
Add the same full-scope guard — query, toggle, both filter chips,
workspace slug, and live-readiness — and bail if any has shifted.

* fix(web): loadMore handles body: prefix correctly per Codex review (round 6)

Codex round 6 P2: `loadMore` was sending the raw `query` to
`api.search`, so page-2 of a `body:foo` search hit the server
with the literal `body:foo` token. Worse, the live-readiness guard
from R5 dropped body: page-2 responses whenever the current
workspace was ready — but body: searches NEED the server (the
local index doesn't carry content), so they should bypass that
guard.

Parse the query in `loadMore` and send the stripped `parsed.text`
when the body prefix is present. Body queries are now exempt from
the live-readiness drop; only non-body server pagination needs to
worry about the path swap.

* fix(web): body queries skip local-index dependency tracking per Codex review (round 7)

Codex round 7 P2: the search-dispatch effect always tracked
`localIndex.bootstrapStateFor` and `localSearch.epoch` for every
workspace, including for body: queries. An SSE-driven epoch bump or
hydration completion mid-flight would re-fire doSearch from offset 0
and wipe an in-flight `loadMore` append on the body: path.

Gate the local-index dependency reads on `!parsed.body`. Body
queries hit server FTS exclusively (the local index doesn't carry
content), so their result set is unaffected by client-side mutations;
skipping the tracking eliminates the loadMore race without losing
incremental-update behavior for local searches.

* fix(web): short-circuit local-state reads in doSearch for body queries per Codex review (round 8)

Codex round 8 P2: even after R7 made the `$effect` skip explicit
localIndex/epoch reads for body queries, `doSearch()` still
synchronously called `readyWorkspaces()` and
`localIndex.bootstrapStateFor()` BEFORE branching on `parsed.body`.
Those reads register as reactive dependencies of the caller, so a
mid-flight SSE bump or hydration completion would still re-fire
doSearch and clobber an in-flight body: `loadMore` append.

Reorder doSearch: parse first, then short-circuit both
`currentReady` and `readyWorkspaces()` to constants when
`parsed.body` is true. Body queries are server-authoritative;
nothing in local state can change their result set, so they pay
no reactivity tax on the local index.

* fix(web): bare body:/content: queries no-op per Codex review (round 9)

Codex round 9 P3: bare `body:` / `content:` with no following
text was falling back to the raw query, shipping the literal
`body:` token to `/search`. `loadMore` had the same fallback.

Short-circuit both paths: when `parsed.body` is true and
`parsed.text` is empty, clear results / bail. There's nothing
useful to search for until the user keeps typing.
2026-05-12 00:46:55 -04:00
xarmian aea4c3435b feat(web): localSearch relevance + ref/number matchers (TASK-1367) (#510)
* feat(web): localSearch relevance + ref/number matchers (TASK-1367)

Phase 3e of the local-first read model (PLAN-1343 / DOC-1342): tune
the MiniSearch relevance config from 3a and add a centralized prefix
parser so search "feels right" on real workspaces.

Relevance tuning
- Boosts: title 3x → 5x (title-vs-tag ties were leaving tag-rich rows
  ahead of cleaner title matches); ref / item_number 2x → 4x so typed
  prefix-shaped refs out-score incidental field hits.
- Per-term fuzz / prefix: length-aware functions disable fuzz for
  terms <4 chars (so `cat` no longer fuzzes into `bat`/`hat`/`category`)
  and prefix-matching for single chars (single-letter terms over-matched
  the rest of the index).

Prefix vocabulary (`parseSearchQuery` — exported)
- `body:foo` / `content:foo` — route to server FTS over the rich-text
  body; the local index doesn't hold `content`.
- `coll:tasks foo` — restrict to a single collection.
- `is:archived` — include soft-deleted rows in the result set.
- `#5` / `item:5` — exact item-number lookup (single doc hit, scoped
  by the caller's collection / archived options).
- `TASK-5` — exact-ref hoist; the matching row jumps to the top of
  the ranked list regardless of MiniSearch's organic score.
- Bare digits (`5`) get treated as `#5` for typing-speed.

Centralization
- Collection page now imports `parseSearchQuery` instead of its own
  ad-hoc `body:`/`content:` regex — one parser, one prefix vocabulary
  across the page and (incoming) CommandPalette wiring (TASK-1365).
- FilterBar tooltip updated to document the full prefix vocab.

Parent: PLAN-1343. Acceptance: title outranks field-only, `TASK-5`
returns its row first, `db migr` matches `Database migration plan`
within the top 3 (verified via the existing tokenize + boost path),
performance unchanged (the new exact-lookup short-circuits avoid
the linear-index walk for the common ref/number cases).

* fix(web): prefix-only queries + is:archived inclusion per Codex review (round 1)

Codex round 1 P2 #1: `is:archived` was advertised but didn't surface
archived rows. The collection page's `items` derived view filtered by
the toggle alone, so archived IDs returned by `localSearch.search()`
were dropped before render. Wire a reactive `parsedSearch` derived
into `items` so the prefix transiently opts in to archived inclusion
without requiring the user to also flip the toggle.

Codex round 1 P2 #2: prefix-only queries (`coll:tasks`, `is:archived`
alone) fell back to `parsed.text || query`, which sent the literal
`is`/`archived` tokens through MiniSearch and surfaced unrelated
rows. Return empty instead — a prefix without query content is a
filter, not a search.

* fix(web): bare digits + preserve search ranking per Codex review (round 2)

Codex round 2 P2 #1: bare-digit queries like `5` left `parsed.text`
populated (`"5"`), so the `!parsed.text` guard suppressed the
exact-number short-circuit and MiniSearch returned incidental hits.
Split the two branches: explicit `#5`/`item:5` uses the parsed
itemNumber path; bare digits always take the exact-lookup path.

Codex round 2 P2 #2: the collection page was converting localSearch
results into a `Set` and filtering items by `has()` — which
preserved the natural `updated_at DESC` order and threw away the
new ref-hoist + boost tuning ranking. Refactor `searchResultIds`
into `searchResultRank`: `Map<itemId, rank>` where rank is the
0-indexed position in the result list. `filteredItems` now filters
by `rank.has(item.id)` and sorts by rank, so the exact-ref hoist
and relevance tuning surface in the UI. Same change applied to the
body: server-FTS path so its order is preserved too.

* fix(web): defer is:archived prefix per Codex review (round 3)

Codex round 3 P2: `is:archived body:foo` doesn't actually surface
archived rows because the server `/search` endpoint hard-filters
`deleted_at IS NULL` at every query branch. The local item source
widens, but the server response can only contain live IDs, so
archived body hits never render.

Pull `is:archived` from `parseSearchQuery` and the FilterBar
tooltip rather than ship a half-working prefix. The existing
`showArchived` UI toggle covers the local search path; the only
missing UX (archived + body) is gated on server work that's out of
scope here. Spawned HT-1370 with a full pickup runbook: add
`IncludeArchived` to `store.SearchParams`, gate the
`deleted_at IS NULL` clauses, plumb `include_archived=true`
through `/search` + the API client, then reintroduce the prefix.

* fix(web): preserve search rank in ListView/BoardView per Codex review (round 4)

Codex round 4 P2: search rank was sorted at the page level, but
ListView and BoardView re-sorted by `sort_order` within each
status group/column, clobbering the exact-ref hoist + boost
ranking whenever two matches shared a group.

Add an optional `preserveOrder` prop to both views (default
false to preserve existing call-site behavior elsewhere). When
true, the in-group / in-column `sort_order` sort is skipped
and the parent's item order wins. The collection page passes
`preserveOrder={searchResultRank !== null}` so the prop only
flips during an active search; the default drag-reorder UX is
untouched when not searching.

TableView already renders parent order by default (its sort is
gated on a user-chosen `sortKey`), no change needed there.

* fix(web): disable item DnD when preserveOrder is on per Codex review (round 5)

Codex round 5 P2: with `preserveOrder=true` (search active),
ListView and BoardView still allowed dragging items. `handleFinalize`
would then write the displayed relevance-ranked subset order back
as `sort_order` on every dropped row, corrupting the workspace's
manual ordering with whatever subset happened to be on screen.

Extend the zone-level `dragDisabled` to OR in `preserveOrder` so
drag is also off while search is active. Column reordering on the
board (separate gesture) stays enabled — columns are status groups,
not search results, so reordering them is still safe.

* fix(web): gate preserveOrder on searchQuery not searchResultRank per Codex review (round 6)

Codex round 6 P2: the R5 fix only set `preserveOrder=true` once
`searchResultRank` was populated. But the search effect
intentionally sets `searchResultRank = null` during the body:
debounce window and the cold-index fallback. `filteredItems`
still renders a subset in those states (via the substring
fallback), so a drag would persist the subset's order as
`sort_order`.

Switch to `preserveOrder={searchQuery.trim() !== ''}` so DnD is
disabled and the in-group sort is skipped whenever a search is
active, regardless of which path populated the result set.
2026-05-11 23:52:38 -04:00
xarmian c59132ad35 feat(web): collection page search uses localSearch (TASK-1364) (#509)
* feat(web): collection page search uses localSearch (TASK-1364)

Phase 3b of the local-first read model (PLAN-1343 / DOC-1342):
replace the collection page's debounced `api.search` round-trip with
`localSearch.search`, making the search box sub-millisecond.

- `handleSearchChange` now runs `localSearch.search(wsSlug, query,
  { collection, includeArchived, limit })` synchronously on every
  keystroke. No debounce — local results are <1ms.
- `body:` / `content:` prefix routes the query back through the server
  FTS endpoint (200ms debounce preserved) so the rich-text body stays
  searchable. The local index intentionally excludes content per
  DOC-1342 decision #4 — content lives on the server.
- A small `$effect` re-runs the local search when `showArchived` or
  `indexReady` flip mid-typing, so cold-load + already-typed queries
  surface results once the index hydrates, and the archived toggle
  re-evaluates the active query.
- FilterBar input gets a `title=` tooltip documenting the `body:`
  prefix UX.
- The existing client-side substring fallback (used when
  `searchResultIds === null` and a query is typed) now covers two
  narrow cases: server-FTS in-flight, and cold-load bootstrap window.

Parent: PLAN-1343. Phase 3b acceptance: keystroke → first results
<50ms P95 — local search runs synchronously inline with the
keystroke handler.

* fix(web): unified search effect handles URL-load body: queries per Codex review (round 1)

Codex round 1 P2: shared/reloaded URLs like `?q=body:foo` previously
only set `searchQuery` via `loadUrlFilters` without triggering the
dispatch, so the local fallback would search the literal string
`body:foo` against titles + fields instead of routing to server FTS.

Refactor: `handleSearchChange` is now a pure setter for
`searchQuery` + URL state. A single reactive `$effect` watches
`searchQuery`, `showArchived`, and `indexReady` and dispatches:
body-prefix → server FTS (debounced 200ms), else → synchronous
MiniSearch. This covers every entry point that mutates `searchQuery`
(typed input, URL load, programmatic clear) with one code path.

* fix(web): snapshot wsSlug/collSlug in search effect per Codex review (round 2)

Codex round 2 P2: navigating between workspaces or collections while a
`body:` query was in flight could land the old response on the new
route. Snapshot `wsSlug` / `collSlug` at effect-run time, route
`api.search` through the snapshots, and include them in the
stale-response guard alongside the query string.

* fix(web): refresh active search on localSearch mutations per Codex review (round 3)

Codex round 3 P2: while a search query was active, SSE-driven
`localIndex.upsert` / `applyDelta` would refresh the items list and
the underlying MiniSearch index, but the page's `searchResultIds`
Set stayed pinned to the original keystroke's results. A matching
row created after the query stayed hidden; an edited row that no
longer matched stayed visible until the user retyped.

Add a reactive per-workspace mutation epoch to `localSearch`:
`epoch(ws)` returns a SvelteMap-backed counter bumped by every
successful `rebuild` / `upsert` / `remove`. The collection page's
search-dispatch `$effect` reads it as a tracked dependency so the
search re-runs whenever the index changes. Server-FTS body queries
also benefit because the same effect path covers them.
2026-05-11 22:58:09 -04:00
xarmian 4e04148f13 feat(web): localSearch MiniSearch store (TASK-1363) (#508)
* feat(web): localSearch MiniSearch store (TASK-1363)

Build the client-side full-text search foundation for Phase 3 of the
local-first read model: a per-workspace MiniSearch index that mirrors
`localIndex` and provides sub-millisecond ranked search over titles +
parsed fields. No UI changes — TASK-1364 wires the collection page and
TASK-1365 wires the global CommandPalette.

- New `web/src/lib/stores/localSearch.svelte.ts`: per-workspace
  MiniSearch index keyed by workspace slug (module-level plain Map —
  search results are derived on demand via `.search()`, not subscribed
  to). Indexed fields with boosts: title (3x), ref (2x), item_number
  (2x), tags, parent_ref, parent_title, collection_slug, and a
  flattened `fields` blob so status / priority / assignee names land
  as searchable terms. Custom tokenizer splits on whitespace + `-_./`
  so `TASK-5` indexes as both `task` and `5`. Public API: `rebuild`,
  `upsert`, `remove`, `reset`, `search(ws, q, opts?)`, `size`.
  Returns `{ id, score }[]` ranked by descending score; callers
  resolve to full rows via `localIndex` (O(1) Map lookup).
- `localIndex` mutation paths now mirror writes into the MiniSearch
  index: `applyDelta`, `upsert`, `remove`, `removeByCollection`,
  `reset` (and the 403-purge error path in `bootstrap`). The cold
  `/items-index` snapshot and the warm IDB hydrate trigger a single
  bulk `localSearch.rebuild(...)` instead of N per-row upserts —
  measurably cheaper at workspace boot.
- SSR-safe: every entry point gates on `typeof window !== 'undefined'`
  so SvelteKit prerender / SSR can't build a server-side index.
- Add `minisearch@7.2.0` (~10KB gz) as an exact-pinned dependency.

Parent: PLAN-1343 (DOC-1342 Phase 3a).

* fix(web): broaden localSearch tokenizer per Codex review (round 1)

Codex round 1 P2: the previous tokenizer only split on whitespace + a
narrow set of separators (`-_./`), so values like `foo:bar`,
`foo,bar`, and `foo(bar)` indexed as a single token. Searches for
`bar` or `foo bar` would miss valid matches.

Broaden to split on any non-letter/non-digit character (`/[^\p{L}\p{N}]+/u`).
This matches MiniSearch's default `SPACE_OR_PUNCTUATION` shape — catches
whitespace, dashes, dots, slashes, colons, commas, parens, brackets,
underscores, etc. — while preserving the `TASK-5` → ['task', '5']
behavior the original regex aimed for.
2026-05-11 22:35:40 -04:00
xarmian f1c9457790 feat(web): 403-driven cache purge for localIndex (TASK-1360) (#507)
* feat(web): 403-driven cache purge for localIndex (TASK-1360)

Per DOC-1342 design decision #3: the local cache is "what you could
see last time you synced." When the server returns 403 mid-session,
the offending entry is purged so the next read doesn't surface
stale-by-permission rows.

## API client (web/src/lib/api/client.ts)

- New `AccessRevokedScope` type + `setAccessRevokedHandler` registration
  hook. Keeps client.ts free of any store import (no circular dep).
- `request()` on 403 parses the URL with `parseAccessRevokedScope`
  (handles item endpoints `/workspaces/{ws}/items/{idOrSlug}` and
  collection-items endpoints `/workspaces/{ws}/collections/{coll}/items`)
  and invokes the handler. Handler failures are caught + logged; the
  403 still propagates as a `PadApiError`.

## localIndex

- New `removeByCollection(ws, collSlug)` — bulk-remove every row in
  a collection. Used when a collection-scoped 403 means the whole
  grant is revoked.
- New `findByIdOrSlug(ws, idOrSlug)` — id-first then slug-scan
  lookup so an item-scoped 403 with a slug URL can resolve to the
  in-RAM id (the SvelteMap is keyed by id).

## App bootstrap (+layout.svelte)

- Registers the handler once at top level: item → `remove` after
  `findByIdOrSlug` resolves; collection → `removeByCollection`.
  Both purges write through to IDB via the existing `persistRemovals`
  path so reloads don't resurrect the stale row.

401 already triggers a /login redirect; this lands as the 403
counterpart. Permission-revocation that doesn't trigger a 403 (e.g.
visibility loss with no fetched row) is explicitly punted per
DOC-1342 #3 — best-effort cache, not authoritative for permissions.

Parent: PLAN-1343. See DOC-1342 design decision #3.

* fix(web): only purge on GET 403s, not write-method 403s (Codex round 1)

[P1] notifyAccessRevoked fired on every 403 — including POST /items
(create), PATCH /items/{id} (update), DELETE /items/{id} (archive),
and the grants/share-link write endpoints. A read-only user who
correctly fails to create or modify an item would have their entire
collection purged from localIndex.

Gate the purge on read methods (GET / HEAD). Write-method 403s mean
"you can read but not write" — the cached rows are still legitimately
visible. Read-method 403s are the canonical "visibility revoked"
signal.

Parent: PLAN-1343.

* fix(web): purge entire workspace on 403, not per-item/collection (round 2)

[P1] Codex caught two related issues:

  1. Pad's server returns 403 from the workspace-access middleware
     (`permission_denied`, `not a member of this workspace`), and
     item-level visibility misses return 404. A 403 on
     /workspaces/{ws}/items/{slug} therefore means workspace access
     is gone — purging only `slug` leaves the rest of the
     workspace's cache stale.

  2. The item URL parser matched `/items/{slug}/grants`,
     `/items/{slug}/share-links`, etc. — owner-only subroutes that
     can 403 after a role downgrade while the item itself is still
     readable. Purging the item on those was incorrect.

Both fixed by collapsing the scope to "the entire workspace":
AccessRevokedScope is now `{ kind: 'workspace'; workspace }`; the
parser just extracts the workspace slug from any `/workspaces/{ws}/...`
path; the handler in +layout.svelte calls `localIndex.reset(ws)`.
The per-item `findByIdOrSlug` and `removeByCollection` helpers added
in round 0 stay in localIndex for future per-item revocation paths
(e.g. server-side `unauthorized` SSE events), but the 403 path no
longer uses them.

Parent: PLAN-1343.

* fix(web): scope 403 purge to read-model endpoints only (Codex round 3)

[P1] Codex caught that workspace-scoped 403s aren't all
workspace-access-revoked signals. Grant-only guests legitimately get
403 on /workspaces/{ws}/members, /workspaces/{ws}/storage/usage,
etc., while their item read access is fine. The previous
"any workspace-scoped 403 → reset" handler would wipe the local index
on every such 403, leaving guest views stuck loading.

Restrict parseAccessRevokedScope to the explicit read-model endpoints
the local-first store actually consumes:

  GET /workspaces/{ws}/items
  GET /workspaces/{ws}/items/{idOrSlug}
  GET /workspaces/{ws}/items-index
  GET /workspaces/{ws}/items-changes
  GET /workspaces/{ws}/collections/{coll}/items

A 403 on any of these means the cache is stale-by-permission. A 403
on anything else stays opaque to the local index.

Parent: PLAN-1343.
2026-05-11 21:15:43 -04:00
xarmian 54203087d0 feat(web): leader-elected SSE + cross-tab BroadcastChannel (TASK-1359) (#506)
* feat(web): leader-elected SSE + cross-tab BroadcastChannel (TASK-1359)

Multiple tabs of the same workspace now share a single SSE
connection via navigator.locks. Per DOC-1342 design decision #2:
one leader holds the EventSource, peer tabs receive deltas via
BroadcastChannel.

- Each connecting tab opens a BroadcastChannel `pad-sync-{ws}` and
  races for an exclusive Web Lock on `pad-sse-leader-{ws}`.
- The lock-holder opens the EventSource and forwards every event
  to local callbacks AND broadcasts to peer tabs. The browser's
  own-message filter keeps the leader from re-dispatching its own
  messages on the way back.
- Peer tabs subscribe to the channel and dispatch leader-forwarded
  events to their local callbacks. They don't open EventSources
  while a leader exists, so the browser sees ONE /api/v1/events
  connection per workspace per browser regardless of tab count.
- On leader-tab close, navigator.locks releases the lock
  automatically and a queued peer takes over (opens its own
  EventSource) without manual intervention.
- Connection status is also broadcast so peer tabs surface the
  same connected / reconnecting / unauthorized indicator the
  leader sees.

Fallback: browsers without navigator.locks (very old / non-browser
environments) skip the election and every tab opens its own
EventSource — N× traffic but correct.

The public API (onItemEvent, onSyncRequired, status, etc.) is
unchanged, so existing callers — sync.svelte, collection page —
just work.

Parent: PLAN-1343. See DOC-1342 design decision #2.

* fix(web): gate BroadcastChannel on navigator.locks support (Codex round 1)

[P1] When BroadcastChannel is available but navigator.locks is not,
every tab falls back to per-tab EventSource AND opens the same
shared channel. Each tab handles its local SSE event AND receives
its peers' broadcasts — item callbacks fire N times across N tabs,
producing duplicate toasts and refetches.

Gate BC opening on `leaderElectionSupported()` (which checks for
navigator.locks). Without leader election, the per-tab EventSource
still delivers events locally; we just don't fan out — correct
and avoids the N× duplication.

Parent: PLAN-1343.

* fix(web): leader promotion sync + BC close on fallback (Codex round 2)

- [P1] When a peer tab is promoted to leader (the old leader's
  tab closed), the new EventSource opens with no Last-Event-ID so
  the server can't replay events from the gap. Query
  navigator.locks BEFORE requesting the lock; if the slot is
  already held, classify ourselves as a future "promotion" and
  fire sync_required on grant so consumers (syncService, the
  collection page) backfill via /items-changes. First leaders on
  fresh page loads don't fire — bootstrap already runs a sync —
  and any mis-classification is idempotent + cheap.

- [P2] The lock-rejection fallback path now closes the
  BroadcastChannel before opening per-tab SSE. Without this, two
  tabs that both reject the lock would each open their own
  EventSource AND keep receiving each other's broadcasts, firing
  callbacks N times. Also triggers sync_required since we may
  have missed events while the rejection landed.

Parent: PLAN-1343.

* fix(web): grant-delay fallback for promoted-leader classification (round 3)

[P1] Two tabs starting simultaneously can both query the lock state
and see "no holder", so both set queryPromoted=false. One wins the
request, the other queues. When the queued tab eventually gets
promoted, queryPromoted=false would skip the sync_required, missing
events from the gap between the old leader's close and the new
EventSource.

Add a grant-delay signal: record `performance.now()` before requesting,
and treat any callback that fires more than 100ms later as a promotion.
Uncontested lock grants are sub-millisecond; a queued tab takes at
least the previous leader's full session. Final `promoted` is
`queryPromoted || grantDelay > 100`, catching both the
"slot-already-held-at-query-time" case AND the
"simultaneous-startup-race" case.

Parent: PLAN-1343.

* fix(web): defer promoted-leader sync until EventSource connects (round 4)

[P1] Promoted/fallback leaders called dispatchSyncRequired immediately
after `new EventSource(...)`, before the replacement SSE stream was
actually subscribed server-side. A mutation between the /items-changes
snapshot (triggered by the sync) and the new stream's first received
event could be missed by BOTH paths.

Defer via a `pendingSyncOnConnect` flag set in the promotion /
fallback paths; the EventSource's onopen and `connected` listeners
fire the sync only after the stream is live. Whichever event arrives
first claims the pending flag.

Parent: PLAN-1343.
2026-05-11 20:58:25 -04:00
xarmian bd8667eadf feat: seq-stamped SSE events + stale-event short-circuit (TASK-1358) (#505)
* feat: seq-stamped SSE events + stale-event short-circuit (TASK-1358)

## Server

- events.Event gains a `Seq int64` field (omitempty). Server populates
  it on item lifecycle events so SSE consumers can reason about
  ordering and contiguity against their /items-changes cursor.
- publishItemEventWithName takes seq as a parameter; all call sites
  in handlers_items.go pass the item's current seq:
  - item_created   → item.Seq
  - item_updated   → updated.Seq
  - item_archived  → re-fetched via GetItemIncludeDeleted (DeleteItem
                     bumps seq but doesn't return the updated row)
  - item_restored  → restored.Seq
  - move target    → moved.Seq

## Web

- ItemEvent gains optional `seq` (matches the server's omitempty).
- localIndex.classifySSEEvent(ws, event) returns
  'no-seq' | 'stale' | 'contiguous' | 'gap'. The collection page
  uses this to short-circuit duplicate / replayed events the
  server's replay buffer re-delivers after tab-resume. Non-stale
  events still call deltaSync because the SSE wire payload only
  carries metadata, not the row data; classify does NOT advance
  the cursor (applyDelta with real row data is the only path that
  does — preserving the IDB invariant from TASK-1356).

Parent: PLAN-1343.

* fix(events): version-restore item_updated event carries seq (Codex round 1)

Version-restore in handlers_item_versions.go was publishing the
item_updated event directly via events.Publish without Seq, bypassing
the new seq-stamped SSE contract from TASK-1358. localIndex's
classifySSEEvent would always return 'no-seq' for those events,
forcing a generic /items-changes refetch instead of allowing the
stale/gap fast paths.

Now passes updated.Seq from the store response.

Parent: PLAN-1343.
2026-05-11 20:29:50 -04:00
xarmian 5fb85534fd feat(web): collection page reads from localIndex (TASK-1357) (#504)
* feat(web): collection page reads from localIndex (TASK-1357)

Wire the collection page (web/src/routes/[username]/[workspace]/[collection]/+page.svelte) to the local-first read model.

- `items` is now `$derived` from `localIndex.getByCollection(ws, coll, { includeArchived: showArchived })`. The collection page no longer fires `/items-index` on every nav — bootstrap is idempotent and runs once per workspace per session.
- New `bootstrap` `$effect` calls `localIndex.bootstrap(ws, { userId })` when the workspace or signed-in user changes, picking up the warm-IDB / cold-/items-index flow from PLAN-1343 Phase 2.
- Mutations (`handleStatusChange`, `handleReorder`, `handleRestore`, `quickCreate`) call `localIndex.upsert(ws, item)` with the canonical post-API row; the derived `items` re-renders automatically.
- SSE handler now triggers `/items-changes` → `applyDelta` via a new `deltaSync` helper instead of refetching the whole collection. SSE merely says "something changed"; the local cursor pulls only the delta. TASK-1358 will refine this to per-event seq-stamped apply.
- `syncService.onSync` routes both incremental and full-refresh signals through the same `deltaSync` path. The legacy /changes payload is no longer threaded into the local store — the seq-cursor /items-changes is canonical.
- The plans cross-collection lookup for task relation labels reads directly from `localIndex.getByCollection(ws, 'plans')` — no extra request.

Parent: PLAN-1343. Depends on TASK-1355 + TASK-1356.

* fix(web): collection page loading + reactive plan labels (Codex round 1)

- [P2] deltaSync() now returns a boolean and the syncService.onSync
  handler only calls markSynced() on a clean catch-up. A transient
  /items-changes failure leaves the legacy cursor untouched so a
  later tab-resume retries instead of pinning at "fresh".

- [P2] `loading` is now derived from BOTH the metadata fetch
  (metaLoading) AND the localIndex bootstrap state. Without this,
  non-empty collections briefly rendered the empty-state CTA while
  items were still hydrating, and scroll-restore could be consumed
  against an empty filteredItems list.

- [P3] `relationLabels` (plan-id → plan-title for task cards) is
  now `$derived` over `localIndex.getByCollection(ws, 'plans')`
  instead of a one-shot fetch in loadCollection. Plans flow into
  the local store as they hydrate, so the badge stays correct
  without a navigation refresh.

Parent: PLAN-1343.

* fix(web): always deltaSync + gate archive toast on success (round 2)

- [P2] syncService.onSync now runs deltaSync for ALL result types,
  including 'caught_up'. SSE only delivers events, not delta data;
  a previous incremental deltaSync failure won't recover without
  a fresh fetch attempt. The localIndex cursor is independent of
  syncService.lastSyncTime, and per-row seq guards make repeated
  calls idempotent.

- [P3] handleBulkArchive now waits for deltaSync to succeed before
  showing a definitive success toast. The server-side deletes are
  already persisted; if the cache fetch fails, surface a softer
  "queued / updating…" toast so the user knows the local view will
  catch up. The deletes themselves are still real.

Parent: PLAN-1343.

* fix(web): optimistic local sort_order on reorder (Codex round 3)

[P2] handleReorder now upserts the row into the local index with
the new sort_order BEFORE awaiting the API. Otherwise ListView,
which calls onReorder without awaiting, resyncs its displayed groups
from the unchanged `items` prop the moment dragging ends and the
rows snap back to the old order until the network PATCH returns.

Clearing `seq: undefined` on the optimistic copy bypasses the
per-row seq guard so the real API response (with a higher seq)
wins on arrival without the guard rejecting it.

Parent: PLAN-1343.

* fix(web): deltaSync 401/403 + error state on bootstrap failure (round 4)

- [P1] deltaSync now resets the local index on 401/403, mirroring
  the auth-error handling in localIndex.bootstrap. If workspace
  access is revoked after the page is mounted, the cached rows
  drop instead of staying visible until reload. Other errors stay
  transient.

- [P2] localIndex.bootstrapState === 'error' is no longer
  conflated with 'ready'. A new `indexError` derived gates a
  dedicated error-state branch in the template with a Retry CTA;
  the misleading "No items yet" empty state no longer fires on a
  transient /items-index failure for a non-empty collection.

Parent: PLAN-1343.

* fix(web): deltaSync on page entry + error surface after revoke (round 5)

- [P1] The bootstrap effect now ALWAYS runs a deltaSync after the
  bootstrap promise settles. Once localIndex is 'ready', bootstrap
  itself no-ops — but an item the user created/updated elsewhere
  (item detail page, dashboard, another tab) while this collection
  was unmounted is still catchable via /items-changes. Without
  this, returning to the collection page after creating an item
  elsewhere could miss the new row until the next SSE event.

- [P2] After a 401/403 from /items-changes, deltaSync now sets a
  `deltaSyncFailed` flag in addition to calling localIndex.reset.
  The reset rolls bootstrapState back to 'cold' but the bootstrap
  effect can't re-fire on the same wsSlug/userId, so without the
  flag the page would pin at "Loading…" forever. The error-state
  banner now triggers on EITHER indexError OR deltaSyncFailed and
  the Retry CTA clears the flag before re-bootstrapping.

Parent: PLAN-1343.
2026-05-11 20:11:29 -04:00
xarmian 13fd9bbda7 feat(web): IndexedDB persistence for localIndex (TASK-1356) (#503)
* feat(web): IndexedDB persistence for localIndex (TASK-1356)

Adds `web/src/lib/stores/localIndexPersistence.ts` and wires it into
the existing localIndex store. Cold loads still hit /items-index;
warm loads paint from IDB before any network IO.

- New `idb` (8.0.3) dependency — small wrapper around IndexedDB.
- Per-workspace database `pad-local-index-{wsSlug}` with two object
  stores (`items` keyed by id, `meta` keyed by 'key' for cursor +
  schemaVersion).
- `LOCAL_INDEX_SCHEMA_VERSION = 1` — bump it on incompatible changes
  to `ItemIndexRow` or the IDB layout; mismatches drop the store and
  force a full /items-index resync. Same pattern as the Yjs
  schemaVersion in `web/src/lib/collab/schemaVersion.ts`.
- `bootstrap` now hydrates from IDB FIRST (paints from cache, flips
  state to 'ready'), then reconciles via /items-changes?since=cursor
  in the background. Cold cache falls through to /items-index and
  persists the result.
- Every mutation path (`upsert`, `applyDelta`, `remove`, `reset`)
  writes through to IDB. Persistence failures degrade silently to
  in-memory only — the read path is never blocked.
- SSR-safe (every IDB call gated on typeof indexedDB !== 'undefined').
- Best-effort: Safari private mode / quota / eviction all surface
  as "empty cache, re-bootstrap from network".

Phase 2 acceptance: warm paint of a populated workspace should now
appear before any /api/v1 request completes.

Parent: PLAN-1343. See DOC-1342 design decision #4.

* fix(web): localIndex reconcile loop + atomic delta persist (Codex round 1)

- [P1] 403 from the warm-load reconcile is no longer swallowed.
  When /items-changes returns `forbidden`, drop the cache and
  re-throw so the registered access-revoked handler (TASK-1360)
  sees it. Other network blips remain non-fatal — cache stands
  and the next reconnect retries.

- [P2] /items-changes is paged at DefaultItemChangesLimit (5000)
  per response. The previous one-shot reconcile would only catch
  up by a single page on a long-offline cache, and bootstrapState
  would pin at 'ready' forever with no later trigger to fetch the
  rest. Loop until the cursor stops advancing; defensively cap at
  50 iterations.

- [P2] New persistDelta() writes rows + meta cursor in a SINGLE
  IDB transaction. The previous separate persistUpserts + persistCursor
  could persist the cursor without the rows that produced it
  (tx interrupt, eviction), leaving the next warm hydrate with a
  cursor that skipped rows. applyDelta + the cold-path bootstrap
  snapshot now both use persistDelta. The standalone persistCursor
  helper is no longer used by localIndex but remains for callers
  that explicitly only need a cursor write.

Parent: PLAN-1343.

* fix(web): cold-path snapshot persists post-merge rows (Codex round 2)

[P1] When the cold-path `/items-index` request is in flight, an SSE
or `applyDelta` write can overlap and stamp a newer row into the
in-RAM index. `mergeRow` correctly skips the stale response row for
that id, but the previous IDB write used `resp.items.map(toSkinny)`
— the unfiltered server response — so the cache got the stale row
under the newer cursor. On the next warm boot, /items-changes?since
would skip that row forever.

Persist the POST-merge in-memory state (state.items.values()) so
the on-disk rows match the in-RAM rows that won the seq guard, and
the cursor stays consistent with them. Uses the same atomic
persistDelta path applyDelta does.

Parent: PLAN-1343.

* fix(web): generation guard on bootstrap + user-scoped IDB cache (round 3)

- [P1] WorkspaceState now carries a `generation` counter. Each
  `reset(ws)` bumps it on the prior state object before dropping
  the workspace from the map. Any in-flight bootstrap captures the
  generation at start and re-checks after every await — if the
  generation has advanced, the bootstrap silently bails out before
  reapplying rows or writing the snapshot to IDB. Without this, a
  sign-out / 403 purge during a slow /items-index could let the
  completed snapshot resurrect just-purged rows.

- [P1] IDB databases are now keyed by (userId, workspaceSlug)
  instead of workspaceSlug alone. The cache is "what THIS user could
  see last sync" — if a different user signs into the same browser,
  their bootstrap opens a fresh per-user namespace and the previous
  user's rows never surface. Anonymous callers (pre-auth) use the
  `anon` namespace. `localIndex.bootstrap` takes an `{ userId }`
  opt the caller passes in (the workspace state captures it on
  first bootstrap and threads it through every persistence call).

Parent: PLAN-1343.

* fix(web): durable applyDelta + required userId opt (Codex round 4)

- applyDelta now ALWAYS includes the existing in-RAM row in the
  persistDelta batch when it wins the seq guard. The previous version
  advanced the IDB cursor past those rows on the assumption their
  upsert()-fired persistUpserts had already landed — but that's a
  fire-and-forget background write that can lose the race or get
  aborted. Result: a row in RAM with seq S, no copy in IDB, and a
  persisted cursor of N > S — warm boot would skip it forever. One
  extra IDB put per redundant row trades cheaply against a missing-
  row class of bug.

- localIndex.bootstrap's `opts.userId` is now REQUIRED (not optional
  with `null` default). Authenticated callers that forget to pass
  it would have silently landed their cache in the shared `anon`
  namespace — a later account on the same browser could then read
  the previous account's rows. TypeScript now enforces an explicit
  choice; pre-auth callers pass null deliberately.

Parent: PLAN-1343.

* fix(web): user-mismatch reset + transient resync retry (Codex round 5)

- [P1] bootstrap() now resets the workspace state BEFORE the
  early-return for 'ready' / pending-promise when the caller's
  opts.userId doesn't match the cached state.userId. Without this,
  a user switch in the same tab could inherit the previous user's
  in-memory map and in-flight promise.

- [P2] WorkspaceState.pendingResync tracks transient delta-sync
  failures on the warm path. When /items-changes fails (non-403)
  after warm cache hydrate, bootstrapState stays 'ready' so the
  UI keeps working off the cache, but pendingResync stays true and
  the next bootstrap() call retries the reconcile instead of
  no-opping. Cold path always finishes with pendingResync=false.

- Documented the permission-revocation-without-row-change limitation
  inline: the cache can't see grants removed without a mutation,
  per DOC-1342 design decision #3 — that's the 403-on-click purge
  flow (TASK-1360), not this layer's job.

Parent: PLAN-1343.

* fix(web): only clear pendingResync when reconcile catches up (round 6)

[P2] The /items-changes reconcile loop has a 50-page safety cap to
prevent pathological tight loops. The previous code unconditionally
cleared `pendingResync` after the loop exited, even on cap-hit, so
a cache that's 50+ pages behind (250k+ rows) would record itself as
fresh and skip retries on future bootstraps. Now `pendingResync` is
only cleared when the loop exited because the server returned no new
rows AND no cursor advance — the genuine "caught up" signal. Cap-hit
leaves `pendingResync = true` so the next bootstrap call resumes.

The permission-revocation-without-row-change concern Codex re-raised
is the explicit DOC-1342 design decision #3 (best-effort cache; 403
purge handles stale-by-permission). The server emits grant-revocation
tombstones through /items-changes per internal/store/grants.go, so the
cache reconciles to-server-truth at the next reconnect. Anything that
slips past that is the 403-on-click purge path (TASK-1360). The
limitation is now explicitly noted inline.

Parent: PLAN-1343.

* fix(web): reentry order + identity-checked inflight cleanup (round 7)

- [P2] Capture `reentry` BEFORE flipping bootstrapState to 'loading'.
  The previous version set state='loading' first, then checked
  `state.bootstrapState === 'ready'` to decide if we're in a
  pendingResync retry — that check always read false, so retries
  re-read IDB instead of just rerunning the reconcile. With
  fire-and-forget IDB writes, re-reading rows whose RAM copy was
  just removed but whose IDB delete hadn't landed would resurrect
  them. Reentry now also skips the 'loading' flip so the UI never
  blanks during a retry.

- [P2] Inflight cleanup is now identity-checked. A `reset()` during
  an in-flight bootstrap can let a fresh bootstrap call re-occupy
  the inflight slot before the stale promise's `finally` runs;
  deleting unconditionally would remove the new entry and let a
  duplicate bootstrap start. We hold the promise in `slot.p` (a
  shared object so the closure can see assignment without TDZ
  issues), and only clear `inflight.delete(ws)` if `slot.p` is
  still the registered promise.

Parent: PLAN-1343.

* fix(web): 401 + empty-cache warm-load (Codex round 8)

- [P1] 401 (unauthorized) from /items-changes reconcile is now
  treated like 403 (forbidden): drop the cache, mark state=error,
  re-throw. The api.items.changes path throws PadApiError with
  code='unauthorized' on 401 (after the redirect-to-login is fired),
  so the cache shouldn't keep showing private rows while the
  redirect is in flight. Other network failures remain transient.

- [P2] A populated IDB cache is now defined as "has rows OR cursor
  > 0", not "has rows". An empty workspace (or a guest with
  item-level grants but no items granted yet) legitimately has zero
  rows but a real meta cursor from the prior sync. The previous
  check forced those workspaces through the cold /items-index path
  on every page load, defeating the warm-load fast path.

Parent: PLAN-1343.
2026-05-11 19:30:07 -04:00
xarmian 979537e4bb feat(web): localIndex in-RAM canonical store (TASK-1355) (#495)
* feat(web): localIndex in-RAM canonical store (TASK-1355)

New Svelte 5 module at web/src/lib/stores/localIndex.svelte.ts that
owns the in-RAM truth for the local-first read model. Per DOC-1342
decision #4: the store is canonical; IndexedDB persistence (next task)
is hydration + write-behind only.

Per-workspace state in a Map keyed by workspace slug:
- items: SvelteMap<itemId, ItemIndexRow> — keyed by item.id
- cursor: monotonic seq cursor as opaque decimal string
- bootstrapState: 'cold' | 'loading' | 'ready' | 'error'

Public API:
- bootstrap(ws): idempotent /items-index hydration (in-flight coalescing)
- getByCollection(ws, collSlug): synchronous filtered read
- applyDelta(ws, changes, cursor): batch upsert/remove + cursor advance
- upsert(ws, row): single-item write for SSE/optimistic paths
- remove(ws, id): single-item delete for SSE archive + 403 purge
- cursorFor(ws) / bootstrapStateFor(ws): reactive getters
- reset(ws): drop all state for a workspace

Defensively strips Item.content on every ingest so a caller passing
a full Item (e.g. from api.items.update) cannot leak the rich body
into the local index — matches the destructure-by-rest pattern in
api.items.listIndex / changes.

Parent: PLAN-1343.

* fix(web): localIndex reactivity + stale-batch guards per Codex review (round 1)

- [P1] WorkspaceState is now a class with `$state` class fields for
  `cursor` and `bootstrapState`. Svelte 5 only permits `$state()` at
  variable-initializer / class-field / constructor-first-assign
  sites — the previous `state = $state({...})` inside `ensureState`
  silently produced a non-reactive object on first hydration, so
  `bootstrapStateFor` getters could stay 'cold' through 'loading' /
  'ready' transitions. Class-field runes give us the same shape
  with reactivity intact.

- [P1] Documented that the store intentionally holds both live and
  archived rows. `applyDelta` only removes on the soft-delete
  `deleted: true` tombstone — status='archived' rows stay so a
  later `showArchived` toggle on the consumer doesn't need a
  refetch. Intended consumer pattern (TASK-1357) filters on
  `fields.status` at render time.

- [P2] `applyDelta` now drops the whole batch when `newCursor` does
  not strictly advance, AND skips individual rows whose `seq` is
  not greater than the cursor at the start of the call. In normal
  /items-changes flow the server filters to `seq > since`, but the
  per-row guard prevents test or future replay callers from
  overwriting newer state with older rows.

Parent: PLAN-1343.

* fix(web): localIndex archive filter + per-row seq guard (Codex round 2)

- [P1] `Item.deleted_at` was missing from the TS interface even though
  the server populates it (`Item.DeletedAt *time.Time` with omitempty).
  Added the field to `Item`, which flows into `ItemIndexRow` via the
  existing `Omit<Item, 'content'>` mapping.

- [P1] `getByCollection(ws, collSlug)` now filters soft-deleted rows
  out by default. The store still holds them (so a `showArchived`
  toggle doesn't need a refetch) but the default view is live-only,
  matching every other collection consumer in the codebase. Callers
  that want archived rows pass `{ includeArchived: true }`.

  Updated the module-level docstring: archived = `deleted_at` set
  (not `fields.status`), and clarified the upsert-vs-delete split on
  the change wire format (`deleted: true` = hard tombstone; soft
  deletes arrive as upserts with `deleted_at` populated).

- [P2] `applyDelta` per-row check now compares against BOTH the
  cursor floor at start AND the existing row's `seq`. Without the
  second check, a delta that legitimately advances the cursor could
  still carry a row whose `seq` is older than what we already hold
  for that id (since `upsert` / SSE paths can store newer rows
  without touching the cursor).

Parent: PLAN-1343.

* fix(web): preserve soft-deleted rows + upsert seq guard (Codex round 3)

- [P1] /items-changes sets `deleted: true` for soft-deleted rows
  (the server's derived view of `deleted_at != nil`), not for hard
  tombstones. The previous applyDelta removed those rows, defeating
  the store's stated invariant that archived items remain queryable
  via `getByCollection(..., { includeArchived: true })`. Now applyDelta
  always upserts on a change — the soft-deleted row keeps its skinny
  payload (with `deleted_at`) and falls out of the default filter
  but stays in the index. Hard deletes still flow through `remove()`.

- [P2] `upsert` now mirrors `applyDelta`'s per-row seq guard: skip
  the write if the incoming row's `seq` is not strictly greater than
  the existing row's `seq`. Without this, a late SSE / out-of-order
  optimistic response could regress a row after a fresher version
  had already landed.

Parent: PLAN-1343.

* fix: items-index returns deleted_at + bootstrap merges instead of clears (round 4)

- [P1] `/items-index` projection now selects `i.deleted_at` and
  `scanItemsIndex` populates `Item.DeletedAt`. Without this the
  local-first client could not distinguish archived rows from live
  ones, so `localIndex.getByCollection`'s default live-only filter
  would surface archived rows as live. Mirrors the projection of
  ListItemsChangesSince which has always carried this column.

- [P2] `localIndex.bootstrap` now merges into the existing state
  using the same per-row `seq` guard as `upsert`/`applyDelta`, and
  the cursor only advances forward. The previous clear-and-replace
  could regress rows that an in-flight `upsert()` or SSE-driven
  write had landed during the bootstrap request, and could reset
  the cursor below an SSE delta that advanced it concurrently.
  Explicit "drop everything" still flows through `reset()`.

Parent: PLAN-1343.

* fix(web): localIndex.getByCollection sorts updated_at DESC, id ASC (round 5)

[P2] SvelteMap iterates in insertion order; live upserts and applyDelta
writes appended rows / kept stale positions, so consumers reading from
getByCollection saw an order that drifted away from the server's
/items-index documented `updated_at DESC, id ASC`. Sort on read so the
collection page sees a stable, server-aligned order regardless of how
recently a row arrived through the in-RAM index. The cost is O(n log n)
per read; the consumer side is expected to memoize via $derived.

Parent: PLAN-1343.
2026-05-11 18:01:16 -04:00
xarmian a5b93c17c9 feat(api): add /items-changes?since=<seq> delta endpoint (TASK-1354) (#494)
* feat(api): add /items-changes?since=<seq> delta endpoint (TASK-1354)

Adds the delta-fetch sibling of /items-index for the local-first
read model (PLAN-1343 / DOC-1342 design decision #1). Clients track
the workspace-scoped monotonic seq cursor returned by /items-index
(TASK-1353) and poll /items-changes?since=<cursor> to apply just
the rows that have mutated — without re-downloading the entire
workspace.

## Endpoint

GET /api/v1/workspaces/{ws}/items-changes?since=<seq>&limit=<n>

  - `since`: exclusive seq lower bound (returns `seq > since`).
    Defaults to 0 → full delta == /items-index modulo ordering.
    Bad input → 400.
  - `limit`: cap on rows. Defaults to 5000, clamped to 50000. Bad
    input → 400.

## Response

  { "changes": [...skinny rows with `deleted: bool`...],
    "cursor": "<decimal MAX(seq) or unchanged since when empty>" }

Soft-deleted rows propagate (no `deleted_at IS NULL` filter on the
backing scan) so a delta consumer can remove them from its local
index without a second roundtrip. Parent metadata enrichment
matches /items-index: the underlying GetItem filters soft-deleted
parents so we never leak parent title/ref for an archived parent.

Cursor contract:
  - Sorted ASC by seq → re-passing the response's cursor as `since`
    on the next poll is no-overlap, no-gap (strictly monotonic seq
    invariant from TASK-1352).
  - Empty response preserves the caller's `since` so position isn't
    lost.
  - Truncated-by-limit responses set cursor to the last row's seq.

## Tests

  - FullDeltaFromZero — three creates, since=0, ascending seq, every
    row deleted=false, cursor=MAX(seq).
  - IncrementalUpdateAndDelete — typical resume flow: snapshot
    cursor, mutate, delta returns exactly the mutated + tombstoned
    rows with the right `deleted` flag.
  - CursorRoundtripsCleanly — empty-poll after consuming, cursor
    preserved.
  - LimitTruncatesAndCursorResumes — paging contract holds end to
    end with no overlap.
  - InvalidParams — bad since / limit values rejected with 400.
  - EmptyWorkspace — cursor round-trips caller's since unchanged.

## Web

TypeScript: `ItemChangeRow = ItemIndexRow & { deleted: boolean }`,
`ItemChangesResponse = { changes, cursor }`. API client gains
`api.items.changes(ws, sinceCursor, opts?)` with the same
defensive content-strip as listIndex so a stray `content: ""`
key from a Go zero-value can never clobber the canonical store.

Parent: PLAN-1343. Depends on TASK-1352 (seq column) and TASK-1353
(seq cursor on /items-index). Unblocks the future client-side
localIndex.applyDelta integration task.

* fix(api): surface tombstones for item-grant users in /items-changes per Codex review (round 1)

Codex round 1 caught that handleListItemsChanges was building its
ItemIDs filter from guestResourceFilter, which itself uses
GuestVisibleResources whose item-grant query filters out
soft-deleted items. The result: a guest or restricted member with
an item-level grant on a single item would see that ID disappear
from the lookup as soon as the item was soft-deleted — and
/items-changes would never emit a `deleted:true` tombstone, so the
client would keep the stale row in its local index forever.

Fix:
  - New Store.GuestVisibleResourcesIncludeDeleted that drops the
    `i.deleted_at IS NULL` / `c.deleted_at IS NULL` filters on
    both collection and item grants so tombstone IDs flow through.
  - New Server.guestResourceFilterIncludeDeletedItems delegate
    pointing at the new store helper. Implementation is shared with
    the live variant via guestResourceFilterCore so the
    member-collection-access + system-collection merge logic stays
    in one place.
  - handleListItemsChanges swaps to the include-deleted variant.

Test: TestGuestVisibleResourcesIncludeDeleted_SurfacesTombstones
covers both variants side-by-side — live drops the soft-deleted
grant, include-deleted preserves it.

* fix(store): assign per-row unique seqs in MigrateItemFieldValues per Codex review (round 2)

Codex round 2 caught that the bulk UPDATE inside
MigrateItemFieldValues gave every affected row the SAME
MAX(seq)+1. A /items-changes?limit=N poll that cut through that
equal-seq group would advance the cursor to the shared seq, and
the next `seq > cursor` poll would silently miss the rest of the
group — the cursor contract requires strict monotonicity.

Switched to a per-row loop inside the migration transaction so
every UPDATE re-reads MAX(seq) and each affected row ends up
with a strictly unique seq. The workspace advisory lock makes
the read-modify-write race-free on Postgres; SQLite's
single-writer rule handles it implicitly.

Trade-off: O(N) statements instead of O(1) for the bulk path.
Option-rename is an admin one-off so the cost is acceptable
(~1s/1000 rows on a warm SQLite connection). If future use cases
demand a larger row budget, a single-statement UPDATE..FROM with
ROW_NUMBER() CTE assigning per-row seqs would also work.

Test: TestMigrateItemFieldValues_PerRowUniqueSeq confirms 5 rows
in a single migration step all get unique seqs.
2026-05-11 13:42:11 -04:00
xarmian 974472799a feat(api): wire real workspace seq into /items-index cursor + rows (TASK-1353) (#493)
* feat(api): wire real workspace seq into /items-index cursor + rows (TASK-1353)

Replaces the placeholder `updated_at`-derived cursor on the
/items-index response with the real workspace-scoped MAX(seq)
introduced by TASK-1352. Each returned row carries its own `seq`
field so clients can reason about ordering without parsing the
cursor.

When the requested scope returns zero rows but the workspace has
items (e.g. ?collection=docs on a workspace whose docs collection
is empty but whose tasks/ideas are not), the cursor falls back to
the workspace's true MAX(seq) via a new Store.MaxItemSeq helper.
That way the client's next /items-changes?since=cursor poll starts
at the right floor instead of replaying every prior mutation from 0.
Empty workspaces collapse to "0".

Encoding: cursor is the decimal-encoded MAX(seq). Treated as opaque
on the wire (clients re-pass it as ?since=). String form leaves
room to switch to base32/etc later without an API break.

TypeScript: `ItemIndexRow` (via `Item`) adds optional `seq?: number`;
`ItemIndexResponse.cursor` docstring updated to reflect the real
seq cursor semantics. `api.items.listIndex` docstring updated.

Tests:
  - TestListItemsIndex_SkinnyProjectionAndShape: cursor now asserts
    decimal-encoded MAX(seq); per-row seq is non-zero.
  - TestListItemsIndex_EmptyResultFallsBackToWorkspaceMax: new test
    covering the cursor fallback on filtered-but-empty results.
  - TestListItemsIndex_CursorMonotonicAcrossMutations: new test
    confirming cursor advances after every mutation.

Parent: PLAN-1343. Depends on TASK-1352 (seq column). Unblocks
TASK-1354 (/items-changes endpoint).

* fix(api): snapshot workspace MAX(seq) before list to close cursor race per Codex review (round 1)

Codex round 1 caught a real race in /items-index cursor computation:
ListItemsIndex ran first, then MaxItemSeq ran in a separate query.
A concurrent INSERT visible to a future /items-changes call could
land between them — the response would be `items: []` with cursor =
the new seq, and a subsequent /items-changes?since=cursor poll
(seq > cursor) would never return that row.

Fix: capture MaxItemSeq BEFORE the list query. Per the workspace's
monotonic counter invariant (TASK-1352) any insert after that
snapshot has seq > captured M, so /items-changes?since=M will see
it. Rows the list DOES observe may have seq > M (a concurrent
insert the list query happened to commit-snapshot); MAX(rows.seq)
bumps the cursor for that case so the client never re-fetches what
was already in the response.

Long-form comment on the handler captures the race scenario and the
invariant that makes the snapshot order safe.
2026-05-11 13:09:49 -04:00
xarmian d6894def4f feat(web): collection page fetches via skinny /items-index endpoint (TASK-1349) (#491)
* feat(web): collection page fetches via skinny /items-index endpoint (TASK-1349)

Replaces every \`api.items.listByCollection(ws, coll)\` call in the
collection page with \`fetchSkinnyItems(ws, coll, includeArchived)\`,
which calls the local-first \`/items-index\` endpoint (TASK-1344)
through the typed client wrapper (TASK-1345). Items now ship
without the rich-text \`content\` body — the bulk of the per-row
wire size — until the user opens an item detail page, which still
goes through its existing full-item fetch.

Call sites updated:
  - loadCollection — primary load + plans-names lookup
  - SSE handler for item_created / item_archived / item_restored / item_updated
  - Sync coordinator's full-refresh fallback

The skinny rows are widened to \`Item[]\` at the boundary by setting
\`content: ''\` on each row. This keeps the existing view component
type contract unchanged and means existing call sites that read
\`item.content\` see an empty string — already a "nothing to do"
sentinel in the markdown-checklist progress branch.

Documented regression — out of scope for this task: non-plans
collections used to display checklist progress derived from item
content's markdown checkboxes. With \`content\` no longer fetched
for the list view, that progress no longer appears. Plans
progress is unaffected (uses /plans-progress, not content
parsing). Re-introducing the feature requires either server-side
progress on the index endpoint or a separate lazy fetch — a
follow-up rather than a blocker for the bandwidth win.

In-scope behavior preserved:
  - Item create/update flow: server still returns full items, dropped
    into the array as-is; sync coordinator's incremental updates
    similarly use the full-item type from the changes feed
  - Server-side FTS search via \`searchResultIds\`: still id-keyed,
    works against skinny rows
  - List / Board / Table view components: already only read fields
    present on the skinny row (title, fields, tags, sort_order…)
  - Detail page fetch: unchanged — still goes through
    \`api.items.get\` which returns the full Item with content

Parent: PLAN-1343.

* fix(api+web): add /collections/{coll}/checkbox-progress endpoint to preserve list-view checklist progress per Codex review (round 1)

Codex round 1 [P2] flagged that the original PR shipped a real
regression: non-plans collections used to compute markdown-checkbox
progress client-side from `item.content`, and the skinny
`/items-index` endpoint dropped `content` from the payload — so
list/board/table progress badges silently stopped appearing on
docs/tasks/custom collections.

This commit closes that gap with a new server endpoint that
computes the same `{item_id, total, done}` counts via
LENGTH/REPLACE arithmetic on the stored content, returning only
the small derived counts. No item bodies cross the wire.

Server (Go):
  - `store.CollectionCheckboxProgress(workspaceID, collectionID)` —
    SQL: `(LENGTH(content) - LENGTH(REPLACE(content, '- [ ]', '')))
    / 5 + (LENGTH(content) - LENGTH(REPLACE(content, '- [x]', '')))
    / 5` for total, the second clause alone for done. Same trick on
    SQLite and PostgreSQL.
  - `handleCollectionCheckboxProgress` — collection-visibility +
    item-grant filter so guests / restricted members can't enumerate
    items they shouldn't see. Mirrors `guestResourceFilter` exactly.
  - Route: `GET /api/v1/workspaces/{ws}/collections/{coll}/checkbox-progress`.
  - Test `TestCollectionCheckboxProgress` covers the math (open +
    done counts), zero-result rows are filtered, unknown collection
    → 404, empty result → 200 + `[]`.

Web:
  - `api.items.collectionCheckboxProgress(ws, coll)`
  - Both call sites in `+page.svelte` (initial `loadCollection`
    non-plans branch + `refreshProgress` non-plans branch) now
    pull from the endpoint instead of parsing `item.content`.
  - Drops the previous "documented regression" comment — the
    feature is fully preserved.

Sub-100-byte response per item (vs. the full content body) so the
bandwidth win from `/items-index` is preserved. The endpoint scans
content server-side, but doesn't transmit it — the original
listByCollection call both scanned AND transmitted content.

Parent: PLAN-1343.

* fix(api+web): plumb include_archived through checkbox-progress per Codex review (round 2)

Codex round 2 [P2] caught that the Archived toggle path lost
checklist progress badges: `CollectionCheckboxProgress` hard-coded
`deleted_at IS NULL`, but the page-side fetch is called with the
same `showArchived` flag that toggles whether archived items
render. With the toggle on, archived non-plan items appeared in
the list but had no `itemProgress` row — the old client-side parse
would have counted them.

Fix: thread `includeArchived` through the call chain.

  - store.CollectionCheckboxProgress(workspaceID, collectionID,
    includeArchived bool) — appends `AND deleted_at IS NULL` only
    when includeArchived is false. Default match the original
    archived-off behavior.
  - handleCollectionCheckboxProgress reads
    ?include_archived=true and forwards.
  - api.items.collectionCheckboxProgress(ws, coll, { includeArchived })
    on the client.
  - +page.svelte's two call sites pass `showArchived` /
    `includeArchived` exactly.

TestCollectionCheckboxProgress now archives one of the seeded
items and asserts:
  - default response excludes the archived item (1 row)
  - ?include_archived=true response includes it (2 rows)

Also clarified the const-doc on `checkboxCountSQL` to reflect the
dynamic deleted-at clause.
2026-05-11 11:51:06 -04:00
xarmian f699d3480e feat(web): virtualize TableView rows via content-visibility (TASK-1348) (#490)
* feat(web): virtualize TableView rows via content-visibility (TASK-1348)

Flat-row variant of the approach landed for ListView in TASK-1346
and BoardView in TASK-1347. Adds
\`content-visibility: auto; contain-intrinsic-size: auto 36px;\`
to \`tbody tr\` so the browser skips layout/style/paint work for
rows that have scrolled out of the table's viewport.

CSS Containment L2 §4.4 historically treated layout/paint
containment as a no-op on table-row elements, but Chrome 122+
(March 2024) and follow-on Firefox / Safari releases lifted that
limitation for content-visibility specifically. The rule is
therefore opportunistic: modern browsers get virtualization,
older engines treat it as a no-op and render unchanged.

Preserved behavior:

  - Sticky thead header (position: sticky; top: 0; on <th>) lives
    in <thead> and is unaffected by per-row paint skipping.
  - Column sort (toggleSort()) lives entirely in <thead> button
    handlers — also untouched.
  - No DnD on this view, so no drop-target preservation work.
  - No protruding badges or absolute-positioned overflow content
    inside rows, so no overflow-clip-margin escape hatch needed
    (cf. PR #489's pr-badge handling on BoardView).

\`contain-intrinsic-size: auto 36px\` matches the actual data-row
height (var(--space-2) padding × 2 + ~20px line-height). The
\`auto\` keyword caches measured heights so rows with progress
bars or wrapped titles keep their natural sizing on re-entry.

Parent: PLAN-1343.

* fix(web): refactor TableView to CSS Grid so content-visibility actually applies per Codex review (round 1)

Codex round 1 [P2] correctly flagged that `content-visibility: auto`
on `<tr>` is a no-op: CSS Containment L2 §4.4 makes layout/paint
containment inactive on internal table boxes, and content-visibility
depends on size containment which is also no-op for table rows. So
the original PR shipped CSS that did nothing — the table-row branch
of CSS Containment defeats the trick that worked for ListView (PR
#488) and BoardView (PR #489).

Refactor: replace `<table>/<tr>/<td>` with `<div role="table">` /
`<div role="row">` / `<div role="cell">` and lay them out with CSS
Grid + subgrid. ARIA roles preserve assistive-tech semantics. Each
row is no longer an "internal table box," so content-visibility +
size containment apply normally.

The grid template is built dynamically because visibleFields depends
on the collection schema:

  grid-template-columns: 70px minmax(200px, 1fr) auto* 90px

Each row sets `grid-template-columns: subgrid; grid-column: 1 / -1;`
so cells align across rows perfectly. Subgrid lands in Chrome 117+ /
Firefox 71+ / Safari 16+; an `@supports not (subgrid)` fallback
inherits the parent's grid template instead.

Preserved behavior:

  - Sticky header — `.table-header { position: sticky; top: 0; }` is
    on the first row (no `<thead>` anymore, but the role is the same).
  - Column sort — `toggleSort()` logic unchanged.
  - Column widths — fixed Ref (70px) / Updated (90px) bracket
    minmax title + auto fields, matching the pre-refactor layout.
  - Progress bar inside title cell — `.col-title` uses
    `flex-direction: column` to stack title + progress.
  - Hover state — `.table-row:not(.table-header):hover` keeps the
    row-level hover behavior.

Virtualization rule (the actual point of the PR):

    .table-row:not(.table-header) {
      content-visibility: auto;
      contain-intrinsic-size: auto 36px;
    }

The header is excluded so sticky positioning isn't fought by paint
skipping.

Parent: PLAN-1343.
2026-05-11 11:18:42 -04:00
xarmian 9768655103 feat(web): virtualize BoardView cards via content-visibility (TASK-1347) (#489)
* feat(web): virtualize BoardView cards via content-visibility (TASK-1347)

Per-column virtualization for the kanban board, mirroring the
approach landed for ListView in TASK-1346. Adds
`content-visibility: auto; contain-intrinsic-size: auto 80px;` to
`.card-wrapper` so the browser skips layout/style/paint work for
cards that have scrolled out of their column's viewport.

`.column-cards` is itself `overflow-y: auto`, so content-visibility's
near-viewport check uses the column as its frame — naturally
per-column. Cards stay mounted so:

  - svelte-dnd-action keeps every drop target in the DOM for
    drag-between-columns + drop-into-empty-column hit-testing
  - column horizontal scroll + column reorder (native HTML5 DnD on
    `.kanban-column`) are unaffected
  - keyboard focus on an off-screen card still resolves via
    querySelector and scrollIntoView rehydrates paint

Intrinsic size is `80px` (vs. ListView's `60px`) because board cards
render with `compact={true}` — they stack status + tags taller than
the list row's single-line layout. The `auto` keyword caches the
real measured height after first paint so subsequent scrolls don't
reflow.

Parent: PLAN-1343.

* fix(web): explicit overflow:visible + spec citation on board card-wrapper per Codex review (round 1)

Codex round 1 [P2] worried that `content-visibility: auto` on
`.card-wrapper` would apply paint containment that clips ItemCard's
`.pr-badge` (positioned at `right: -6px`, deliberately protruding
past the card's right edge).

Per CSS Containment Module Level 2 §4 ("content-visibility"),
`content-visibility: auto` applies paint containment ONLY when the
element is "not relevant to the user" — off-screen, when the badge
isn't being painted anyway. On-screen elements receive only layout
containment, which does not clip ink overflow.

Even so, declaring `overflow: visible` explicitly is the cheapest
defense against a future style sweep that might silently add
`overflow: hidden` to wrappers, and pairs naturally with the
in-source comment citing the spec. Codex's concern is now
documented, addressed, and auditable.

No behavior change for spec-compliant browsers — the badge
already rendered correctly on-screen. The explicit declaration
makes the contract self-describing.

* fix(web): use overflow-clip-margin:6px to preserve PR badge protrusion per Codex review (round 2)

Codex round 2 [P2] correctly pushed back on round 1's spec reading:
per CSS Containment L2 §3.4 / §4, `content-visibility: auto` applies
paint containment continuously (including on-screen), and paint
containment clips ink overflow regardless of an explicit
`overflow: visible` declaration (overflow:visible is treated like
overflow:clip at used-value time when paint containment is active).

The proper fix is `overflow-clip-margin: 6px` — a CSS property
specifically designed to extend the paint-clip rectangle past the
element's content box by a fixed margin, without affecting layout.
6px matches `.pr-badge`'s outward offset (`right: -6px`) exactly,
so the badge renders unchanged from the pre-virtualization layout.

Browser support for overflow-clip-margin is identical to
content-visibility:auto (Chrome 90+, Firefox 102+, Safari 16.4+) —
every browser that ships the virtualization also ships the escape
hatch. Older browsers ignore both properties and render
unvirtualized (which is also correct).

Comment in the file now cites the actual spec section and the
correct mental model — no more "auto applies paint only off-screen"
misreading.

* fix(web): grow overflow-clip-margin to 12px for badge shadow + hover per Codex review (round 3)

Codex round 3 [P3] caught that 6px covered the badge's border-box
offset (`right: -6px`) but not the ink overflow from
`box-shadow: 0 1px 3px` (~3px blur) and the hover
`transform: scale(1.05)` (~2px growth at typical badge widths).
12px covers offset + shadow + hover with a small safety margin.
2026-05-11 10:51:31 -04:00