1.7.5 — 2026-09-25 · compare ignores empty-vs-null, Esc closes the link popover
- Compare versions no longer reports empty fields as changed. Older snapshots store a cleared field as
""(or[]), newer ones asnullor no key, so a save could list "Published At, Updated on" as changed and the panel showed "— → —".undefined,null,""and[]now count as the same empty value, in both the list summaries (changedFields) and the panel (diffDocs).0andfalseare still values. Found on real data. - Esc closes the link popover wherever the focus is. It only worked from the URL box, so a popover opened by clicking a link (the caret stays in the text) ignored Esc, and in full screen it held full screen open too, since full screen's Esc waits for the popover. One Esc now closes the popover; the next exits full screen.
No template changes: consumers only bump the package.
1.7.4 — 2026-09-25 · preview pane, full-screen editing, version compare, link editing, inline code toggle
- Live preview is a proper pane. With the preview open, the document page is two panes: the form, and the preview at a width you set by dragging the divider (or arrow keys on it; double-click resets), remembered per browser. A "Phone" toggle narrows the frame to 390px. Versions and media usage move under the form. Before, form, preview and versions shared one flex row: the preview started at 480px and grew, and the form's fixed 18rem meta column left the main fields a sliver. Toggling the preview no longer remounts the form, so unsaved edits survive it.
- DocForm's meta column follows the form's width, not the viewport's (measured with a size observer; under 48rem). Narrower than that, the meta panel sits on top as a one-line toggle listing its fields; it opens itself when a meta field has an error. Collapsed fields stay in the DOM and still submit. (Not a CSS container query:
container-typecontains layout, which trapsposition: fixeddescendants such as the full-screen editors.) - Full-screen editing beside the preview. Top-level richText and html fields get a ⤢ button: the editor fills the window left of the live preview (same width split), with Show/Hide preview and Done; Esc exits. It is restyled in place, never moved, so the form, autosave, ⌘S and the preview push keep working. State shared via
admin/editor-ui.svelte.js. - The preview follows autosave. DocForm fires
highseam:autosavedafter a draft autosave; the doc page reloads the preview (Mode A). PreviewBanner keeps the preview's scroll position across those reloads (sessionStorage, keyhighseam:preview-scroll:<path>). Consumers: re-copyroutes/admin/[collection]/[id]/+page.sveltefromtemplates/for the pane layout; DocForm's change comes with the package. - Compare versions. The versions list shows which fields each save changed, and "Compare" opens a review panel (Sanity-style): every kept version on the left; on the right the selected version's changes field by field, against the save before it or against the latest. Text shows a word diff; richText and html a paragraph diff with the words marked inside changed paragraphs (blocks as one line: type + text fields; link URLs shown); other values before → after. Restore from the panel (confirms first). Snapshots load on demand from the new
admin/[collection]/[id]/versionsendpoint (signed in + update access), two at a time, cached. Diffing lives inadmin/version-diff.ts(Myers, with a readability pass) and is unit-tested (npm run test:diff, now part ofnpm test). Consumers: re-copy the[id]route folder fromtemplates/(page, page server, and the newversions/+server.ts). - Links in rich text can be edited and removed. The link button used
window.prompt, which always opened empty, so a link could never be seen, changed or removed. A popover in the editor now adds a link to the selection, or edits the link under the caret (⌘K, or click a link in the text): URL, new tab, nofollow, Open ↗, Remove link. Bare domains gethttps://;javascript:/data:are refused (the server strips them too). Works in list items; nested editors in block fields handle their own. </>toggles. It unwraps inline code the selection touches instead of only ever wrapping (⌘E). Bold, italic, strike, code and link buttons light up while the caret is inside that format.- HtmlInput's link prompt shows the current URL (it opened empty); clear it to remove the link.
1.7.3 — 2026-09-25 · every block sub-field inside richText gets its editor
- Block nodes inside a richText field:
embed,gallery,dateand tags (jsonwithadmin.editor: 'tags') sub-fields get their editors (EmbedInput, GalleryInput, a date input, TagsInput), as BlocksInput rows already did. They fell through to a text input, so a gallery block dropped into a body showed[object Object],[object Object]for its images, and typing in the box replaced the stored value with a string. Found on PawScapes' Gallery and "Video / social embed" rich-text blocks. - No text-input fallthrough for structured values. A
blocksorjsonsub-field, or any sub-field whose value is an object or array (a type added later), edits as JSON in a textarea, as in BlocksInput.
1.7.2 — 2026-09-18 · blocks inside richText get real editors
- Block nodes inside a richText field:
richText,arrayandlinksub-fields get the real editors. RichTextInput's block-node editor still rendered a richText sub-field as a textarea (the 1.7.1 fix only covered BlocksInput) and anarraysub-field as a text input, so afaqblock (items array) or asectionImageblock (nested body) dropped into a body could not be authored. Nested RichTextInput / BlocksInput / LinkInput in callback mode, same as insideblocksrows. Found on PawScapes when the landingsectionImage+faqblocks were allowed inside venue bodies (content splitting). richTextcoercion recurses into block nodes' sub-fields (asarray/blocksalready did): a nested richText is sanitized server-side (its inline HTML was passing through untouched), and nested arrays, links, embeds and galleries get their JSON-string / upgrade paths.
1.7.1 — 2026-09-16 · richText inside blocks
- richText sub-fields inside
blocks/arrayrows get the real editor. BlocksInput rendered them as a plain textarea bound to the node array, so the form showed[object Object],[object Object]and a save wrote that string over the nodes. RichTextInput now takessubmitHidden,onChangeandshowLabellike the other row-capable inputs and BlocksInput uses it;galleryLayoutsis forwarded from DocForm → BlocksInput → nested rows. Found on PawScapes' destination landing sections (asectionImageblock with abodyrichText) the first time one was opened in the admin. - LinkInput no longer overflows inside a block row. The collection select carried
w-fullfrom the shared input class and won the cascade overw-auto, shoving the relation picker outside the card. The select isw-auto shrink-0and the row wraps.
1.7.0 — 2026-08-14 · Wave 2
The link primitive plus rich-text ergonomics. Zero stub changes.
linkfield — internal document reference OR external URL, one value, one picker:{ doc: { collection, id } } | { url }, optionalnewTab. Internal links are stored by id and resolved at render, so slug renames never break stored links — the no-redirects policy expressed as a field type; the internal-link rewrite migration this replaces should never need to run again. DeclarerelationTo: ['pages', …]to enable the internal picker (omit for URL-only fields). External urls accept http(s), mailto:, tel:, and site-relative paths — protocol-relative and javascript: are rejected.linkHref(value, routes)maps resolved targets to your URLs and returns null for unpopulated/dangling links (skip the anchor, don't render a dead '#');linkAttrs()pairstarget="_blank"withrel="noopener". Usage scans (referencedIds/findUsage) count link references, and top-level link fields populatedoc.resolvedat depth ≥ 1. Migration contract: raw href strings lift to{ url }— including inside blocks (see below).- Coercion now recurses into blocks and array rows. The upgrade paths (link/embed strings, gallery bare-id arrays) previously held only at the top level; the values they were written for live inside blocks. Caught by our own e2e before it half-landed on the consumer.
- Slash-command insert. Type
/in an empty paragraph: a filterable menu of everything the + menu offers (headings, lists, image, gallery, embed, your components); type to filter, arrows + Enter, Escape closes. - Drag to reorder nodes. Every rich-text node card grows a ⠿ handle; drop indicator shows the insertion point.
Known contract, unchanged from every ref type before it: values inside blocks/array rows don't auto-populate — resolve refs at read (consumers already do this for relationships in blocks). linkHref returning null for unpopulated internal links is the graceful half of that contract.
Deliberately not shipped (Wave 2 had three items on the roadmap): edit-inside-MediaPicker stays behind its trigger — no field report has shown metadata-fixing-during-selection yet.
Stub changes: none.
1.6.0 — 2026-08-13 · Wave 1
The enhanced-fields wave, in the consumer-pressure-tested order: repeater first (gallery rides it), then gallery, embed, and dedupe. Primitives, not elements — see ROADMAP.md for what was deliberately not built.
- The repeater.
arraysub-fields inside blocks render as real add/remove/reorder rows — recursively — instead of a JSON textarea. Rows everywhere (blocks and arrays) now collapse to a one-line summary. Row add/remove/move correctly mark the form dirty (they're button clicks; no native input event fired before). - Tags chips editor.
jsonfields that are really string lists opt in withadmin: { editor: 'tags' }— type, Enter adds, ✕ removes, Backspace eats the last chip. The hand-typed-JSON-array era ends. (Old JSON-string values parse on first edit.) gallery— field type and rich-text node. Ordered upload refs with per-item alt/caption overrides riding theresolveMedia()precedence; identity stays on the media doc. Editor: MediaPicker multi-add, focal-aware thumbs, ✎ Edit modal per item, reorder. Per-placementlayout(from the newmedia.galleryLayoutsregistry — the node stores the NAME, your CSS owns the look; the reference renderer emitsdata-layout) and optional per-placementaspect. Populates like relationships: items gain adocat depth ≥ 1. Migration contract: bare id arrays are accepted and lifted to the item shape — existing stored galleries survive their first save.embed— field type and rich-text node. Paste a URL; the server resolves it at save to{ provider, embedId, title?, thumbUrl? }via a provider registry. Core resolves YouTube, Vimeo, and any provider you register with a fixed oEmbed endpoint — nothing else; there is no oEmbed discovery on purpose (fetching arbitrary editor URLs server-side is an SSRF vector; a registry of fixed hosts isn't). Third-party HTML is never stored — renderers build iframes from the allowlist. Reference renderer: youtube-nocookie, click-to-load facade off the resolved poster frame, plain-link fallback for unresolved URLs (flagged loudly in the editor). Pasting a bare provider URL into an empty rich-text paragraph becomes an embed node. Migration contract: raw URL strings lift to{ url }. Resolution is best-effort: no network → provider + id still fill from URL parsing alone.- Upload dedupe (content hash). Uploads carry a managed
contentHash(SHA-256); identical bytes return the existing document (transient_deduped: trueflag) instead of a second doc + storage key. Applies toimportMediatoo (new per-item action'deduped') — but only when externalKey identity found nothing, and never overwriting provenance. Read access applies to the dedupe lookup, so an invisible doc can't leak through a hash hit. Opt out per collection:upload: { dedupe: false }. Table-backed upload collections need acontentHashcolumn.
Stub changes (run npx highseam-init --check-stubs after upgrading):
src/routes/admin/[collection]/[id]/+page.server.ts— load() also returnsgalleryLayouts: cms.config.media?.galleryLayouts ?? [].src/routes/admin/[collection]/[id]/+page.svelte— passesgalleryLayouts={data.galleryLayouts}to<DocForm>.src/routes/api/[collection]/import/+server.ts— result type includes the'deduped'action.
1.5.2 — 2026-08-10
- Upload sub-fields inside blocks get ✎ Edit… too. 1.5.0 wired in-context media editing on upload fields and rich-text upload nodes but skipped upload sub-fields inside
blocks/arrayrows (and inside rich-text block nodes). Now all picker surfaces offer the same edit modal. Patch-sized fix, shipped ahead of the roadmap queue on purpose. - ROADMAP revised against the consumer pressure-test: Wave 1 resequenced (repeater first — gallery's editor is repeater machinery), migration standing rule (new node types accept-and-upgrade previously stored shapes), X dropped from core embed resolvers into the registry (our own argument, applied to our own list), upload dedupe (content hash) added to Wave 1, crop rect added to Wave 3 with its evidence acknowledged, link field's evidence status corrected, collection queries redesigned around a named registry.
Stub changes: none.
1.5.1 — 2026-08-10
QoL, from the first production day of 1.5.0: the aspect select was the one control on an upload node whose effect an editor couldn't see until the page rendered.
- The node preview shows the crop the reader will get. When
fields.aspectis set, the upload node's thumbnail renders with the exact three properties the renderer emits —aspect-ratio,object-fit: cover, focalobject-position— and re-renders the moment you change the select. Absent aspect = natural thumbnail, unchanged. RichTextRendererno longer falls back to filename foralt. Missing alt now rendersalt=""(decorative) instead of a screen reader announcing "IMG_2041.jpg". Filename remains a fine lightbox label and a poor caption; captions were already alt-only. (Adopted from the consumer's own renderer fix — if yours copies the old template, take the same rule.)
Stub changes: none.
1.5.0 — 2026-08-10
Media round 3, from a real editing session: in-context editing, honest previews, per-placement presentation. The organizing principle stays clean — the media doc is identity (bytes, focal point, metadata defaults); a placement is presentation (how this one use renders).
- In-context media editing. Every image chip — upload fields and rich-text upload nodes — gets ✎ Edit…, opening the media doc's edit surface (focal point, aspect previews, alt/caption/credit/tags) in a modal over the doc you're editing. Fields come from the collection's own schema rendered with the same FieldInput as the full page — one editing surface, not a second, lesser form. Saves PATCH through the public API (access, hooks, validation all apply). The host form is untouched: modal inputs are namespaced so a host save can never pick them up (both venue and media declaring
tagswas live ammunition), input events stop at the modal boundary so host dirty-tracking never fires, and Cmd-S inside the modal saves the modal, not the doc under it.→ mediafull-page deep link stays. - Honest previews. A selected image on an upload field now renders as an actual thumbnail (focal-aware, like every thumb since 1.3.0), not a filename pill — an editor placing photography sees the photograph. Full size is one click away (lightbox in the edit modal).
- Per-placement aspect on rich-text upload nodes.
fields.aspect(ratio string; absent = natural — fully non-breaking) with a small select on the node card. The sanitizer whitelists it: parseable ratios only, everything else onfieldsis dropped.RichTextRendererrenders aspected uses withaspect-ratio+object-fit: coversteered by the doc's focal point; custom renderers should do the same. - One registry, scoped. Placement vocabulary comes from the same
media.aspectsregistry, via optionalcontexts: ('preview' | 'placement')[]per entry (absent = both). Crop strips and placement selects filter accordingly; the node stores the ratio string, so config choices stay reversible.
Deferred, on purpose: editing from inside the MediaPicker grid (modal-in-modal; the chip covers the flow that had evidence) and placement aspect on upload fields (their rendering is consumer layout; no field report yet).
Stub changes (run npx highseam-init --check-stubs after upgrading):
src/routes/api/[collection]/options/+server.ts—?schema=1mode returning the collection's client field definitions (auth-gated). Without it, the edit modal opens with the image and focal picker but no metadata fields.
1.4.0 — 2026-08-08
Stub drift made detectable. 1.3.0 half-landed on its only production consumer: the focal picker worked, but the aspect previews never appeared, because the consumer's admin route stub predated the release and never passed mediaAspects through. No error anywhere — config valid, install correct, deploy green. Stubs are consumer-owned on purpose (that's what makes them customisable); the gap was that drift was invisible.
npx highseam-init --check-stubsdiffs every consumer stub against the installed templates and reports, per file, the template lines your copy is missing. Your own customisations are never flagged — only absences. Exits non-zero on drift, so it can gate a deploy or run in CI after everynpm i highseam@latest.- Dev-mode drift warning in the editor. When a collection declares a
focalPointfield but the admin route never passedmediaAspects(the prop isundefined, not[]), DocForm nowconsole.warns in dev with the exact stub, the exact line to add, and the--check-stubspointer. Silent half-features are the worst kind of broken. - Changelog convention, starting retroactively with 1.3.0: any release that touches a template stub names the file(s) under a "Stub changes" heading. If you scaffolded before that release, run
--check-stubs.
Stub changes: none. (Both fixes live in the packaged library and CLI.)
1.3.0 — 2026-08-08
The media editor round, driven by a production field report: one original in storage, art direction by focal point, zero variant uploads.
- Focal-point picker. Upload docs that declare a
focalPointfield (json) now get a hotspot editor in place of the plain image preview — click or drag to place the point, Clear to remove. Writes through the normal form pipeline, so dirty tracking, autosave-draft, and Cmd-S all behave. - Aspect registry + live previews. Declare the ratios your frontend actually renders in
media.aspects(config), e.g.{ name: 'card', ratio: '3:2' }. The editor shows a live crop preview per aspect, steered by the focal point, so editors see every crop the moment they place the point. No registry → picker still works, no strips. mediaSrc(doc, { width, aspect | height, quality })builds Cloudflare Image Resizing URLs (/cdn-cgi/image/…) withgravityfrom the stored focal point. No dimensions → returns the original URL unchanged, so it's safe to adopt everywhere before enabling resizing on your zone. PlusparseRatio(), both exported fromhighseam.- Thumbnails honor the focal point everywhere — admin gallery grid, the media picker modal, and relation-picker chips all crop toward the hotspot (
focalPointnow surfaces on the options endpoint /RelOption). - Fixed:
highseam/themes/*.cssandhighseam/assets/*imports. The exports map rewrote every subpath todist/*.js, breakinghighseam/themes/lodestone.cssandhighseam/assets/favicon.svgfor consumers. Non-JS assets now pass through untouched. - README gotcha for harmless
UNRESOLVED_IMPORTdep-scan noise on$app/*imports (silence withoptimizeDeps: { exclude: ['highseam'] }). - Starter config now ships a two-aspect registry as a worked example.
Deliberately not shipped: manual per-aspect crop rectangles. The hotspot covers the aspect-cropping cases we have field evidence for; rects add a storage schema and a four-handle UI we'd be guessing at. Send evidence.
Stub changes (added retroactively — run npx highseam-init --check-stubs if you scaffolded before 1.3.0):
src/routes/admin/[collection]/[id]/+page.server.ts— load() now returnsmediaAspects: cms.config.media?.aspects ?? [].src/routes/admin/[collection]/[id]/+page.svelte— passesmediaAspects={data.mediaAspects}to<DocForm>.- Starter
cms.config.tsgained amedia.aspectsexample (starter, not a stub — existing configs are unaffected).
1.2.1 — 2026-08-07 · security-adjacent fix, upgrade recommended
- init now protects
.gitignore. The SvelteKit skeleton ignores.env*but not wrangler/highseam conventions, so a consumer could commit.dev.vars(yourCMS_SECRET) and — worse, by default —.data/, the local dev store, which contains user password hashes and all content after the first dev session.highseam-initnow appends.data,.dev.vars*and!.dev.vars.exampleto.gitignore(idempotent), and ships a.dev.vars.example. - If you scaffolded before 1.2.1: add those three lines to
.gitignoreyourself. If a.dev.varswas ever committed, rotateCMS_SECRET(invalidates sessions + preview tokens; users just log in again). If.data/was ever pushed to a public repo, treat those user passwords as compromised and scrub the history.
Reported from the field by the highseam.com build. (The system works.)
1.2.0 — 2026-08-06
Out-of-the-box experience, audited cold and fixed. (First release informed by walking the public-registry path as a stranger rather than by a field report.)
- The quickstart no longer 500s. Following the old init steps literally on a fresh SvelteKit app crashed the entire site on first
npm run dev— Tailwind was a prerequisite hidden in a comment.highseam-initnow wires the@tailwindcss/viteplugin intovite.configand imports the theme in your root layout automatically (when those files are recognizable; exact manual steps otherwise), detects your package manager, and prints the one REQUIRED install command as step 1 — not a footnote. Init leaves a running app or says precisely why not. - The starter config now shows the flagship. It previously scaffolded users + a bare pages collection — no uploads, no blocks, no embeds, so the media system and block editor were invisible until you hand-wrote config. The starter is still three collections, but now: a
mediaupload collection (with the metadata conventions pre-declared), and pages with a sidebar cover image, tabbed layout, and rich text carrying image embeds, page embeds, and a typed Callout block. - Empty states guide instead of shrug. A pristine dashboard shows a three-step "First steps" card; unfiltered empty lists say "No pages yet — create the first one" instead of the filter-implying "No documents match".
- The original theme has a name:
lodestone. A lode is a seam of ore; a lodestone is the miners' compass. A highseam theme is just a CSS token file —highseam/themes/lodestone.cssships as the canonical documented reference: copy, change values, import afterhighseam.css.
1.1.0 — 2026-08-05
Media provenance + import — designed against a real 8.4k-image mirror pipeline rather than guessed.
cms.importMedia()+POST /api/<collection>/import. Create-or-update keyed on importer-declared identity (source.externalKeyby default — never URL equality, because signed origin URLs rot and re-mint differently for the same photo). JSON batches (≤50/call, per-item outcomes created/updated/unchanged/error + summary) for paced, resumable, idempotent runs; multipart mode for bytes+provenance in one call.- Adopt-in-place.
storageKeycan reference an object that already exists in storage: a corpus backfill creates documents without moving a byte. Adopted keys are importer-owned — highseam never auto-deletes them (_adoptedmarker; replacing bytes through the CMS clears it). - Re-mint semantics. Same
externalKeywith a new originUrl / new storage key (e.g..jpg→.webp) updates the existing doc: identity and usage backlinks survive; no versions on media (replace is the point). firstUsedInis history. Importer-settable at create, never silently overwritten afterwards; live usage stays computed (findUsage).- Provenance search + panel.
source.externalKey,firstUsedIn.slugandfirstUsedIn.titlejoin the picker's search fields when declared — find an image by the venue slug you remember. Media edit pages gain a "Used in N places" panel with backlinks, plus source / origin / first-used provenance and an importer-owned indicator. - Importer timestamps: state them inside
source(e.g.mirroredAt) — systemcreatedAtremains the import-run date by design.
1.0.0 — 2026-08-04 · renamed to highseam
The project was previously published as svelte-payload. That name described where it came from rather than what it is, and leaned on someone else's trademark. It is now highseam — a config-driven CMS that runs at the edge and can sit on top of the database you already have.
Nothing in your data moves. The documents table, collection slugs, _status, version stores, globals, _locks, and every R2 key are keyed by your collection slugs, never by the package name. Your cms.config.ts API is unchanged. Your /api/... and /admin/... URLs are unchanged.
Migrating an existing app:
npm uninstall svelte-payload && npm i highseam
npx highseam-init --migrate # add --dry-run to previewThe codemod rewrites imports, svelte-payload.css → highseam.css, the Tailwind @source path, and --sp-canvas → --hs-canvas across src/. Two things it can't do for you: the session cookie is now hs_token, so everyone is logged out once; and if you re-copy stubs from templates/ on upgrade (recommended), do that as usual.
Renamed identifiers: --sp-canvas → --hs-canvas, data-sp-theme → data-hs-theme, cookie sp_token → hs_token, bin svelte-payload-init → highseam-init.
Also in 1.0.0 — admin quality pass
- The save bar is sticky and acknowledges the click. It no longer scrolls out of reach on long forms, the pressed button shows a spinner and "Saving…" immediately instead of leaving you waiting for a toast, and both buttons disable during the request. An "Unsaved changes" hint sits alongside.
- Validation errors can no longer hide. With
groupsAs: 'tabs', an error on a field in an inactive tab was completely invisible — the save just appeared to do nothing. Tabs now show an error-count badge, the form jumps to the first tab containing an error, and the field is scrolled into view.
Engine (dist/, installed from the tarball) upgrades cleanly via npm i. Scaffolded route stubs are ejected at init time and do NOT auto-update — re-run templates by hand if a release notes a stub change.
0.6.0 — 2026-08-04
Media system v2, part one: finding images, and describing them once.
- Modal media browser. Upload fields now open a real library: thumbnail grid, search-as-you-type, mime/unused filters, sort, paging, keyboard traversal (arrows + enter), and upload-from-inside-the-modal. Nothing is loaded until you open it, so it doesn't care how large the library is. Ordinary relationships keep the lightweight inline typeahead — the right tool for a short list.
- One endpoint grew rather than a new one appearing.
/api/<collection>/optionsnow supportspage/limit(returningtotalDocs/totalPages),mime,unused,sort, and — for upload collections — OR-matching acrossfilename,alt,caption,credit,tagsandsource.originUrl.Whereis AND-only, so the OR runs as parallel access-checked queries merged by id. - Media metadata by convention, not injection. If your upload collection declares
alt/caption/credit/tags/focalPoint, they become the inherited defaults; if it doesn't, nothing changes. The framework never forces a schema on your media collection. resolveMedia(doc, overrides)in core so every renderer resolves inheritance identically: image metadata as the default, per-use overrides on top,undefinedmeaning inherit and''meaning "deliberately empty". Ships withfocalObjectPosition()— honours a hotspot through plain CSSobject-fit: cover, no transform layer required.- Usage scanning:
cms.referencedIds(slug)andcms.findUsage(slug, id), covering direct relationship/upload fields and rich-text embeds. Computed on demand (O(collections), not O(documents)) rather than as a maintained index — always correct, no write-path complexity. Powers theunusedfilter and, later, safe-delete warnings. - Tests:
npm test(richtext split + media inheritance, 46 assertions).
0.5.1 — 2026-08-04
Media UX, and one consistency pull-back.
- Inline upload from the field. Upload fields now take a file directly — choose or drag-drop — creating the document and selecting it, instead of making you leave for the media collection and come back. It POSTs multipart to the collection's own create endpoint, so access control, mime/size limits and hooks apply exactly as they do for a normal create. No bespoke upload route. Works in plain fields, rich-text embeds, and block sub-fields.
- Media collections render as a gallery. Collections with
upload: trueget a thumbnail grid in the admin list instead of a table of filenames, with select-to-bulk still available. Non-upload collections are unchanged. - Pull-back: one picker everywhere.
BlocksInputwas the last editor still using a plain<select>for relationships (and had grown a bespoke upload preview on top of it). It now usesRelationPickerlike the other two — deleting the special case and gaining search, async lookup, chips and thumbnails for free. - Actionable storage error. "No file storage configured" now names the missing R2 binding and the command to create the bucket, instead of being a bare 500. The demo
wrangler.tomlships the R2 binding uncommented —wrangler devsimulates it locally with no remote bucket.
0.5.0 — 2026-07-25
Post-paste reformatting in the rich text editor. 0.4.1 made paste produce correct nodes; this makes the resulting block structure editable, which is the other half of the paste-then-reformat loop.
- Split on Enter. Enter in a text node splits it at the caret; the tail becomes a new paragraph and the caret follows it. Shift+Enter still inserts a
<br>. Implemented withRange.extractContents(), so inline markup is closed and reopened correctly rather than sliced mid-tag. - Merge on Backspace at node start. Joins into the previous text node with the caret at the join point. Embeds (upload / relationship / block / divider) are never concatenated into — backspace on an empty node removes that node, and a non-empty one is left alone.
- Selection-aware block type. New Text / H2 / H3 / ❝ toolbar buttons. With a collapsed caret they retype the whole node (as before); with a sub-range selected they split the node into before / selected / after and apply the type to the middle. "Select
The rules.→ H2" now does the obvious thing instead of promoting the entire paragraph. - Empty-inline pruning.
extractContents()leaves an empty<strong></strong>shell on the far side of a cut; newpruneEmptyInline()(exported from core) strips those so they don't accumulate across splits. - Markdown input rules on an otherwise-empty paragraph:
##,###,>,-/*,1.,---. They only fire when the marker is all that's been typed, so they can't eat existing content. - Keyboard: ⌘/Ctrl+⌥+1/2/3 → Text/H2/H3 on the focused node; ⌘/Ctrl+Shift+V pastes as plain text, discarding source markup.
- Add buttons insert after the focused node instead of always appending.
- Tests:
npm run test:richtext(jsdom) covers split inside<strong>, across an<a>with attributes, at node start/end, the three-way selection split, merge join points, and empty detection. - No config or stub API changes; pure engine upgrade.
0.4.0 — 2026-06-13
Consumer-migration hardening (from a real 8.4k-row adoption).
- Fix: consumer Tailwind now scans the shipped admin. The
svelte-payload.cssstub carries@source "../node_modules/svelte-payload/dist"— without it, utilities used only inside packaged components (tab strips, grids, the relation picker) never generate and the admin layout collapses. Add this line if you're upgrading an app scaffolded before 0.4.0. - Fix: SEO attributes survive an
html-field save. The sanitizer now keeps arelwhitelist (nofollow / noopener / noreferrer / sponsored / ugc) andtarget="_blank"(force-adding noopener), instead of collapsing every link torel="noopener". Applies to bothhtmlfields and rich-text inline links.javascript:/data:hrefs are still dropped. - Async relationship search — new
/api/<collection>/options?q=&ids=endpoint (access-checked); the picker debounce-fetches matches instead of requiring every row preloaded, so relationships/uploads to large (table-backed, 8k-row) collections scale. The searchable picker now also drives rich-text upload/relationship embeds and block sub-fields (previously plain<select>s over preloaded options). - Toast store renamed
toasts.svelte.ts→toast-store.svelte.tsto remove the case-insensitive twin ofToasts.svelteentirely (belt-and- suspenders over the 0.3.2.jsfix). - Stub changes:
svelte-payload.css(the@source), plus the picker/embeds ride the packageddist(no stub edit needed). Re-copysvelte-payload.cssfromtemplates/on upgrade.
0.3.2 — 2026-06-13
Packaging bugfix (thanks to a consumer report).
- Fix: case-insensitive self-import in the packaged
dist. The toast runes moduletoasts.svelte.tscompiles totoasts.svelte.jsand sits beside theToasts.sveltecomponent. Importing it extensionless (./toasts.svelte) case-insensitively resolves toToasts.svelteitself on macOS/Windows, breaking every consumer — it only surfaced in the built package, since dev-mode resolution probes.tsbefore the ambiguous exact match. Now imported as./toasts.svelte.js(and the edit-stub template emitssvelte-payload/admin/toasts.svelte.js). The template builder no longer double-appends.jsto specifiers that already carry it.
0.3.1 — 2026-06-13
Editing-loop polish (no new systems — tightening the daily experience).
- Searchable reference picker — relationship/upload fields now use a type-to-filter picker with removable chips (single + hasMany), replacing the raw
<select multiple>. Filters the loaded option list client-side, so it's fine against large targets. Image options show thumbnails. - Toasts + action UX — a toast system replaces inline save banners; document actions show an inline spinner while running, honor the
confirmflag, disable during the request, and surface the returned message as a toast (then refresh the doc + preview). - Unsaved-changes guard + ⌘/Ctrl-S — the edit form tracks dirty state and warns before in-app navigation or tab close; Cmd/Ctrl-S saves. The guard re-arms after each save.
- No config or stub API changes; pure engine (
dist/) upgrade. Re-copy the edit stub only if you want the toast-based save feedback.
0.3.0 — 2026-06-12
Live preview, theming, and directory-scale admin (8k-row feedback, round 2).
- Live Preview —
admin.preview.url(doc, token)on a collection. The admin mints a short-lived HS256 token (yourCMS_SECRET) and shows a resizable preview iframe beside the form: reloads on save (Mode A) and postMessages the in-progress doc as you type (Mode B). New front-end exportsvelte-payload/preview(verifyPreviewToken,onPreviewData,isPreviewing) +svelte-payload/PreviewBanner.svelte— wire the public site in ~10 lines.src/routes/preview/venues/[id]is a reference implementation. - Theming —
admin.theme: 'light' | 'dark' | 'system'(default system). All colors are overridable--color-*tokens; the editor canvas is its own--sp-canvastoken so it can stay "paper" in a dark shell — or not. - Tabs + sidebar —
admin.groupsAs: 'tabs' | 'sections'; per-fieldadmin.position: 'sidebar'for a meta column. - Faceted filters —
admin.filters: ['suburb', 'dog_friendly', ...]renders combinable controls (select / yes-no / has-content / contains) that buildwherequeries (straight SQL in table mode), with URL-synced state. - Bulk actions — row select → bulk publish / unpublish / delete, and bulk run a document action (e.g. enrich 50 rows).
- Image-preview fields (
admin.preview: 'image'), per-doc "Open on site" link,upload hasManygalleries. - Stub change: list + edit page stubs gained filters/bulk/preview — re-copy from
templates/to pick them up in an existing app.
0.2.0 — 2026-06-12
Adoption feedback release (driven by a real 8,400-row deployment).
- Field grouping —
admin.group: 'Details'on any field renders it in a collapsible section; ungrouped fields stay on top. Tames wide table-backed edit forms. - Custom document actions —
admin.actions: [{ label, url, confirm? }]on a collection renders buttons on the edit page that POST{ collection, id }to your endpoint (e.g. "Generate with AI"). - Friendly D1 table errors —
no such table/no such columnon table-backed collections now explain the local-vs-remote D1 situation and the wrangler seed command instead of dumping a SQLite stack. defineGlobal()helper for config symmetry withdefineCollection().uploadfields supporthasMany— image galleries on one field (multi-select in admin, array population in APIs).- Breaking (custom adapters only):
DatabaseAdapter.initreceivesAdapterStoreInfo[]instead ofstring[](carried over from 0.1.x table-mode work; no change needed unless you wrote your own adapter). - Stub change:
admin/[collection]/[id]/+page.sveltegained action buttons — re-copy from templates if you want them in an existing app.
0.1.0 — 2026-06-10/11
Initial release: config-driven collections; generated REST + GraphQL + admin; auth (sessions + API keys); access control (boolean or query constraints); hooks; drafts & versions (publish/unpublish/restore/prune, autosave, scheduled publishing); blocks; structured rich text with embedded components (+ XSS-safe sanitizer + renderer); HTML fields; media/uploads (fs + R2); globals; localization; document locking; plugins (config transforms); generated TS types; table-backed collections (D1 column-per-field); packaging + svelte-payload-init scaffolder.