mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-22 18:43:45 +00:00
d3bd1958c5
* 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.
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