Files
pad/web
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
..
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
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