Files
pad/web
xarmian 9ce52a8116 feat(web): unparented filtering UI and persistence (TASK-2099) (#932)
* feat(web): unparented filtering UI and persistence (TASK-2099)

Phase 2 of PLAN-2095: consumes Phase 1's local-first `is_unparented`
projection with a FilterBar chip, local filtering across list/board/table,
URL + saved-view round-trip, parent-filter mutual exclusivity, restricted
clearing (DR-2), and an explicit public-share skip of the reserved
`$unparented` saved-view discriminator (DR-5).

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

* fix(web): close mutex/URL-collision/sync gaps in unparented filter (TASK-2099)

Round-1 Codex findings: reserve the URL round-trip under the $unparented
key (not a plain "unparented" param) so a grandfathered schema field
literally named "unparented" can't collide; extend the parent/unparented
mutex to the legacy "phase" alias and enforce it on whole-state URL/
saved-view application, not just the interactive handlers; keep the URL's
$unparented param in sync with the chip's effective state in both
directions, including when a pre-hydration default-view apply's intent
only becomes effective once projection metadata resolves afterward.

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

* fix(web): close pending-resync and optimistic-row gaps in unparented filter (TASK-2099)

Round-2 Codex findings: gate unparented-metadata availability on
localIndex.pendingResyncFor so a warm-cache boot's possibly-stale
persisted capability bit can't expose the chip/filter before the
follow-up reconcile confirms it (P1); force a URL re-sync whenever a
restricted caller's stuck intent is cleared, not just on an effective-
value transition, so a leftover $unparented URL param can't suppress
the user's default saved view; assign activeFilters when a URL carries
only the unparented pseudo-param (no field filters), closing a stale-
parent-survives-the-mutex gap; and treat is_unparented=undefined as
optimistically passing the filter so a just-created loose item doesn't
flash out of view before its authoritative projection bit lands.

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

* fix(web): narrow the pending-resync gate to confirmed restriction only (TASK-2099)

Round-3 Codex findings: the round-2 pending-resync gate on
unparentedMetadataAvailable was too blunt — it destroyed a legitimate
URL/saved-view intent the instant it loaded mid-resync (even one about
to confirm unrestricted a moment later), and could wedge the chip
hidden forever after a live permission upgrade since pendingResync is
cleared only by bootstrap()'s own reconcile loop, never by a
deltaSync()-triggered resync. Split the concern: ordinary
rendering/filtering availability no longer depends on pendingResync
(consistent with the rest of the local-first model, which doesn't
block on it either); a new, narrower unparentedConfirmedRestricted
(metadata confirmed false AND no resync in flight) gates only the
destructive "clear a stuck intent" action.

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

* fix(web): let deltaSync's own reconcile loop clear pendingResync (TASK-2099)

Round-4 Codex finding: pendingResync had no owner for a resync
triggered mid-session by the collection page's SSE/periodic-sync-driven
deltaSync (as opposed to bootstrap()'s own reconcile loop). Left stuck
true forever, it meant a caller downgraded and later re-upgraded within
the same session would never see the "confirmed restricted" transition
fire, letting a stuck pre-downgrade unparented-filter intent silently
reactivate on the upgrade instead of staying cleared. Add
localIndex.markCaughtUp so deltaSync's own catch-up branches clear it,
mirroring exactly what bootstrap()'s internal loop already does for
the path it owns.

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

* fix(web): close cross-navigation URL race and epoch-guard markCaughtUp (TASK-2099)

Round-5 Codex findings: gate the unparented URL-sync effect on
!metaLoading (not just indexReady) so navigating between collections
in an already-hydrated workspace can't rebuild the destination URL
using the previous collection's stale filter state before
loadUrlFilters() has run for the new route; reset the transition-
detection memo on route change so a value carried over from a
previous collection can't mask a genuine effective-state transition
in the new one; and epoch-guard localIndex.markCaughtUp so one
caller's catch-up confirmation can't clear pendingResync out from
under a different, concurrently-landed resync (SSE/periodic-sync/
bootstrap can all trigger one).

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

* fix(web): replace ordering-fragile metaLoading gate with an explicit flag (TASK-2099)

Round-6 Codex finding: the unparented URL-sync effect's !metaLoading
guard implicitly assumed it always re-evaluates strictly after the
load-triggering effect resets metaLoading for a new route -- not a
documented cross-effect guarantee -- and metaLoading also flips back
to false on a failed load, where loadUrlFilters() never ran. Replace
it with an explicit urlFiltersLoaded flag that loadCollection sets
true only once loadUrlFilters() has genuinely completed for the
current route, reset by an effect declared before the sync effect so
Svelte's same-flush declaration-order guarantee, not an incidental
timing coincidence, is what keeps them correctly sequenced.

Claude-Session: https://claude.ai/code/session_01EZ6yr6pAUFb1uffan912ra
2026-07-14 12:27:28 -04:00
..
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00
2026-03-26 01:52:36 +00:00

Pad Web UI

SvelteKit 2 + Svelte 5 frontend for Pad, compiled to static files and embedded into the Go binary.

Development

npm install
npm run dev          # Dev server at localhost:5173 (proxies API to localhost:7777)
npm run build        # Production build to build/
npm run check        # Type checking with svelte-check

When developing, run the Go backend separately with make dev from the project root.

Building for Production

Do not build in isolation. Always use make build from the project root — this builds the web frontend, then compiles the Go binary with the build output embedded via //go:embed.

Stack

  • Svelte 5 with runes ($state, $derived, $effect)
  • SvelteKit 2 with adapter-static (SPA mode)
  • Tiptap block editor with markdown round-trip
  • svelte-dnd-action for drag-and-drop in board/list views
  • SSE for real-time updates
  • TypeScript throughout

Structure

src/
  routes/                    SvelteKit pages
    +layout.svelte           App shell (sidebar + main)
    +page.svelte             Landing/redirect
    [workspace]/
      +page.svelte           Dashboard (collections, phases, activity)
      +layout.svelte         SSE connection per workspace
      [collection]/
        +page.svelte         Collection view (board/list)
      [collection]/[item]/
        +page.svelte         Item detail + editor
      conventions/            Purpose-built conventions page
      playbooks/              Purpose-built playbooks page
      settings/               Workspace settings
  lib/
    api/client.ts            HTTP API client
    components/
      layout/                Sidebar, navigation
      editor/                Tiptap editor, raw markdown editor
      fields/                FieldEditor, relation picker
      items/                 ItemCard, ItemDetail
      collections/           BoardView, ListView
      common/                StatusBadge, badges, modals
      search/                CommandPalette
      activity/              ActivityFeed
    stores/                  Svelte 5 reactive stores
      workspace.svelte.ts    Workspace state
      collections.svelte.ts  Collection + item state
      ui.svelte.ts           Sidebar, mobile state
    types/index.ts           TypeScript types and constants
  app.css                    Global styles and design tokens