From d65af73cf36d883e645a421e662ed4963551fd0b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 31 May 2026 12:17:13 +0300 Subject: [PATCH 1/5] persist post drafts to localstorage + editor state reducer mirror the editor's working state (title, body, tags, path, format and in-post image blob refs) into localStorage so nothing typed is lost before it reaches the PDS. drafts are debounced, flushed on pagehide, restored on load, and cleared on save/delete. a status pill ("draft saved locally" vs "saved to your PDS") and a restore banner with a discard action make it clear whether what you see is only cached locally or actually committed. image blob refs round-trip through lexToJson/jsonToLex so restored uploads still save correctly (plain JSON.stringify mangles the CID). also consolidates the editor's ~13 useState fields into a single useReducer (postEditorReducer.ts) with named seed/restore/revert/clear actions. Co-Authored-By: Claude Opus 4.8 --- src/lib/drafts.ts | 102 ++++++++++ src/routes/PostEditor.tsx | 321 ++++++++++++++++++++++++++------ src/routes/postEditorReducer.ts | 141 ++++++++++++++ src/styles/global.css | 41 ++++ 4 files changed, 549 insertions(+), 56 deletions(-) create mode 100644 src/lib/drafts.ts create mode 100644 src/routes/postEditorReducer.ts diff --git a/src/lib/drafts.ts b/src/lib/drafts.ts new file mode 100644 index 0000000..1f227d3 --- /dev/null +++ b/src/lib/drafts.ts @@ -0,0 +1,102 @@ +/** + * Local draft persistence for the post editor. Everything the user types is + * mirrored into localStorage so it survives a reload/crash/tab-close until it's + * actually written to the PDS. Drafts are the safety net; the PDS record stays + * the source of truth — a draft is cleared once it's been saved. + * + * Keys are DID-scoped so a different signed-in account never reads another's + * drafts. Image blob refs round-trip through `lexToJson`/`jsonToLex` because a + * `BlobRef` holds a live `CID` that plain `JSON.stringify` mangles (after which + * `getBlobCidString` returns "[object Object]"). + */ + +import { jsonToLex, lexToJson, type LexValue } from "@atproto/lex" +import type { UploadedImage } from "./providers/index.ts" + +const VERSION = 1 +const PREFIX = "sh:draft" + +export interface PostDraft { + /** Schema version; entries with a different version are ignored on load. */ + v: number + title: string + description: string + tags: string + pathTemplate: string + body: string + /** id of the richtext provider this draft is being written in. */ + providerId: string | null + /** cid -> uploaded in-post image, blob refs serialized via lexToJson. */ + images: Record + /** cid of the server record this draft was based on (null for a new post). */ + baseCid: string | null + /** epoch ms of the last persist, for the "saved … ago" label. */ + savedAt: number +} + +/** Storage key for a post draft. Pass `{ newPub }` for an unsaved new post. */ +export function draftKey( + did: string, + target: string | { newPub: string | null }, +): string { + if (typeof target === "string") return `${PREFIX}:${did}:${target}` + return `${PREFIX}:${did}:new${target.newPub ? `:${target.newPub}` : ""}` +} + +/** Serialize the in-session uploaded-image map for storage. */ +export function serializeImages( + images: Map, +): Record { + const obj: Record = {} + for (const [cid, img] of images) obj[cid] = img + // lexToJson recurses into each image's BlobRef, encoding the CID losslessly. + return lexToJson(obj as unknown as LexValue) as Record +} + +/** Revive a stored image map back into live UploadedImage values. */ +export function deserializeImages( + images: Record | undefined, +): Map { + const map = new Map() + if (!images) return map + try { + const revived = jsonToLex(images as never) as unknown as Record< + string, + UploadedImage + > + for (const cid of Object.keys(revived)) map.set(cid, revived[cid]) + } catch { + // Corrupt image payload — drop it; the markdown text is still recovered. + } + return map +} + +/** Load a draft, or null if absent/unreadable/stale-schema. */ +export function loadDraft(key: string): PostDraft | null { + try { + const raw = localStorage.getItem(key) + if (!raw) return null + const parsed = JSON.parse(raw) as PostDraft + if (parsed?.v !== VERSION) return null + return parsed + } catch { + return null + } +} + +/** Persist a draft. Failures (quota, private mode) are swallowed. */ +export function saveDraft(key: string, draft: Omit): void { + try { + localStorage.setItem(key, JSON.stringify({ v: VERSION, ...draft })) + } catch { + // Storage unavailable/full — nothing we can do; don't break the editor. + } +} + +export function clearDraft(key: string): void { + try { + localStorage.removeItem(key) + } catch { + // ignore + } +} diff --git a/src/routes/PostEditor.tsx b/src/routes/PostEditor.tsx index 5067477..035b023 100644 --- a/src/routes/PostEditor.tsx +++ b/src/routes/PostEditor.tsx @@ -2,12 +2,20 @@ import { getBlobCidString, l } from "@atproto/lex" import { markdown } from "@codemirror/lang-markdown" import { languages } from "@codemirror/language-data" import CodeMirror, { EditorView } from "@uiw/react-codemirror" -import { useEffect, useMemo, useRef, useState } from "react" +import { useEffect, useMemo, useReducer, useRef } from "react" import ReactMarkdown, { type Components } from "react-markdown" import { Link, useNavigate, useParams, useSearchParams } from "react-router" import remarkGfm from "remark-gfm" import { useAuth } from "../auth/AuthProvider.tsx" import { blobImageUrl, cdnImageUrl } from "../lib/bsky.ts" +import { + clearDraft, + deserializeImages, + draftKey, + loadDraft, + saveDraft, + serializeImages, +} from "../lib/drafts.ts" import { markdownToPlaintext } from "../lib/markdown.ts" import { cidFromSrc, @@ -36,6 +44,21 @@ import { templatizePath, type DocumentRecord, } from "../lib/repo.ts" +import { + editorReducer, + initEditorState, + type EditorFields, +} from "./postEditorReducer.ts" + +/** A coarse "x ago" label for the draft status; refreshed on each re-render. */ +function relativeTime(ts: number): string { + const s = Math.round((Date.now() - ts) / 1000) + if (s < 10) return "just now" + if (s < 60) return `${s}s ago` + const m = Math.round(s / 60) + if (m < 60) return `${m}m ago` + return `${Math.round(m / 60)}h ago` +} /** Read an image file's pixel dimensions (for the stored aspect ratio). */ async function readImageSize( @@ -83,11 +106,32 @@ export function PostEditor() { } = useDeleteDocument() const { mutate: uploadImage, isPending: uploadingImage } = useUploadImage() - const [title, setTitle] = useState("") - const [description, setDescription] = useState("") - const [tags, setTags] = useState("") - const [pathTemplate, setPathTemplate] = useState(DEFAULT_PATH_TEMPLATE) - const [body, setBody] = useState("") + // All mutable form state lives in one reducer; see postEditorReducer.ts. We + // destructure for reads and dispatch named actions for writes. `setField` is a + // shorthand for the common text-input case. + const [state, dispatch] = useReducer( + editorReducer, + searchParams.get("pub"), + initEditorState, + ) + const { + title, + description, + tags, + pathTemplate, + body, + coverFile, + coverRemoved, + selectedPubRkey, + selectedProviderId, + formError, + savedAt, + restoredAt, + stale, + } = state + const setField = (field: keyof EditorFields, value: string) => + dispatch({ type: "set", field, value }) + const pathDialogRef = useRef(null) // The live CodeMirror view, so we can insert uploaded images at the cursor. const viewRef = useRef(null) @@ -104,9 +148,8 @@ export function PostEditor() { }, []) // Cover image: a newly-picked file to upload, or a flag to drop the existing - // one. Otherwise the document's existing coverImage blob is kept. - const [coverFile, setCoverFile] = useState(null) - const [coverRemoved, setCoverRemoved] = useState(false) + // one (both in the reducer). Otherwise the document's existing coverImage is + // kept. const coverObjectUrl = useMemo( () => (coverFile ? URL.createObjectURL(coverFile) : null), [coverFile], @@ -117,9 +160,10 @@ export function PostEditor() { } }, [coverObjectUrl]) - // Synchronous form validation only; save/delete failures are derived from the - // mutations above. - const [formError, setFormError] = useState(null) + // `formError` is synchronous form validation only; save/delete failures are + // derived from the mutations above. The draft-status fields (`savedAt` drives + // the "saved locally" pill, `restoredAt` the recovery banner, `stale` the + // server-changed warning) also live in the reducer. const saveError = formError ?? (saveErr @@ -129,11 +173,8 @@ export function PostEditor() { : null) // For a new post the target publication is user-selectable, defaulting to - // ?pub=. For an existing post it's fixed to whichever publication its - // `site` points at. - const [selectedPubRkey, setSelectedPubRkey] = useState( - searchParams.get("pub"), - ) + // ?pub= (the reducer's initial `selectedPubRkey`). For an existing post + // it's fixed to whichever publication its `site` points at. const publication = useMemo(() => { if (!publications.length) return null if (existing?.site) { @@ -146,6 +187,14 @@ export function PostEditor() { ) }, [publications, existing, selectedPubRkey]) + // localStorage key for this post's draft: the rkey for an existing post, or a + // per-target-publication "new" slot for an unsaved one. + const currentKey = useMemo(() => { + if (!did) return null + if (rkey) return draftKey(did, rkey) + return draftKey(did, { newPub: publication?.rkey ?? null }) + }, [did, rkey, publication]) + // New posts default to whatever richtext format the other posts in the target // publication use, so a post lands in a format the blog's reader understands. const siblingProviderId = useMemo(() => { @@ -183,11 +232,9 @@ export function PostEditor() { }, [isNew, publication, allDocs]) // The format this post is read from / written in. New posts: the dropdown - // selection, else the sibling default, else markpub. Existing posts: whatever - // provider read them (null = an unrecognised format → read-only below). - const [selectedProviderId, setSelectedProviderId] = useState( - null, - ) + // selection (`selectedProviderId` in the reducer), else the sibling default, + // else markpub. Existing posts: whatever provider read them (null = an + // unrecognised format → read-only below). const activeProvider = useMemo(() => { if (isNew) return ( @@ -214,20 +261,38 @@ export function PostEditor() { const md = editableDoc.markdown.trim() if (seededUriRef.current !== editableDoc.entry.uri) { seededUriRef.current = editableDoc.entry.uri + // The server body is always the dirty-comparison baseline, even when a + // local draft supersedes it in the inputs — so isDirty (and the "saved + // locally" status) reflect the draft differing from what's on the PDS. seededBodyRef.current = md - setTitle(v.title ?? "") - setDescription(v.description ?? "") - setTags((v.tags ?? []).join(", ")) - setPathTemplate(templatizePath(v.path, rkey)) - setBody(md) + const draft = did ? loadDraft(draftKey(did, rkey)) : null + if (draft) { + uploadedImagesRef.current = deserializeImages(draft.images) + dispatch({ + type: "restoreDraft", + draft, + stale: draft.baseCid !== editableDoc.entry.cid, + }) + } else { + dispatch({ + type: "seed", + fields: { + title: v.title ?? "", + description: v.description ?? "", + tags: (v.tags ?? []).join(", "), + pathTemplate: templatizePath(v.path, rkey), + body: md, + }, + }) + } } else if ( md !== seededBodyRef.current && body.trim() === seededBodyRef.current ) { seededBodyRef.current = md - setBody(md) + dispatch({ type: "adoptBody", body: md }) } - }, [editableDoc, rkey, body]) + }, [editableDoc, rkey, body, did]) // For a new post, adopt the publication's inferred path shape once it loads // (unless the user has already opened the dialog and set one themselves). @@ -235,9 +300,22 @@ export function PostEditor() { useEffect(() => { if (!isNew || pathSeededRef.current || !siblingPathTemplate) return pathSeededRef.current = true - setPathTemplate(siblingPathTemplate) + setField("pathTemplate", siblingPathTemplate) }, [isNew, siblingPathTemplate]) + // For a new post, restore a local draft once the target publication resolves + // (the draft is keyed by it). Runs once; new posts have no server baseline, so + // any restored content simply reads as dirty/unsaved. + const newSeededRef = useRef(false) + useEffect(() => { + if (!isNew || newSeededRef.current || !did || !publication) return + newSeededRef.current = true + const draft = loadDraft(draftKey(did, { newPub: publication.rkey })) + if (!draft) return + uploadedImagesRef.current = deserializeImages(draft.images) + dispatch({ type: "restoreDraft", draft, stale: false }) + }, [isNew, did, publication]) + const cmExtensions = useMemo( () => [markdown({ codeLanguages: languages }), EditorView.lineWrapping], [], @@ -311,11 +389,65 @@ export function PostEditor() { siblingPathTemplate, ]) + // Mirror the editor's working state into localStorage so nothing typed is lost + // before it reaches the PDS. Debounced; when the form matches the server record + // (or is empty) the draft is dropped instead. The image map is keyed by blob + // CID and serialized via lexToJson so restored uploads still save correctly. + // `flushRef` holds the pending write so we can flush it on tab close. + const baseCid = editableDoc?.entry.cid ?? null + const flushRef = useRef<(() => void) | null>(null) + useEffect(() => { + if (!currentKey) return + if (!isDirty) { + flushRef.current = null + clearDraft(currentKey) + dispatch({ type: "markClean" }) + return + } + const at = Date.now() + const payload = { + title, + description, + tags, + pathTemplate, + body, + providerId: activeProvider?.id ?? null, + images: serializeImages(uploadedImagesRef.current), + baseCid, + savedAt: at, + } + const write = () => { + saveDraft(currentKey, payload) + dispatch({ type: "markSaved", at }) + } + flushRef.current = write + const t = setTimeout(write, 600) + return () => clearTimeout(t) + }, [ + currentKey, + isDirty, + title, + description, + tags, + pathTemplate, + body, + activeProvider, + baseCid, + ]) + + // Close the debounce window: write any pending draft synchronously when the + // page is being hidden/unloaded. + useEffect(() => { + const onHide = () => flushRef.current?.() + window.addEventListener("pagehide", onHide) + return () => window.removeEventListener("pagehide", onHide) + }, []) + /** Insert markdown at the cursor (or append if the editor isn't mounted). */ function insertAtCursor(text: string) { const view = viewRef.current if (!view) { - setBody((b) => (b ? `${b}\n\n${text}` : text)) + dispatch({ type: "appendBody", text }) return } const { from, to } = view.state.selection.main @@ -328,10 +460,10 @@ export function PostEditor() { /** Upload an image file and insert a markdown image referencing its blob. */ async function handleImageFile(file: File) { - setFormError(null) + dispatch({ type: "setFormError", message: null }) if (!file.type.startsWith("image/")) return if (file.size > 1_000_000) { - setFormError("Images must be under 1MB.") + dispatch({ type: "setFormError", message: "Images must be under 1MB." }) return } const { width, height } = await readImageSize(file) @@ -350,16 +482,51 @@ export function PostEditor() { // The src is the bare blob CID; the preview resolves it to a URL. insertAtCursor(`![${alt}](${cid})`) }, - onError: (e) => setFormError(errorMessage(e, "Failed to upload image.")), + onError: (e) => + dispatch({ + type: "setFormError", + message: errorMessage(e, "Failed to upload image."), + }), }) } + /** Forget the restored local draft and revert the form to the server record. */ + function discardDraft() { + if (currentKey) clearDraft(currentKey) + flushRef.current = null + uploadedImagesRef.current = new Map() + const fields: EditorFields = + existing && rkey + ? { + title: existing.title ?? "", + description: existing.description ?? "", + tags: (existing.tags ?? []).join(", "), + pathTemplate: templatizePath(existing.path, rkey), + body: seededBodyRef.current, + } + : { + title: "", + description: "", + tags: "", + pathTemplate: siblingPathTemplate ?? DEFAULT_PATH_TEMPLATE, + body: "", + } + dispatch({ type: "revert", fields }) + } + + /** Reset draft state after the post is written to (or removed from) the PDS. */ + function clearDraftState() { + if (currentKey) clearDraft(currentKey) + flushRef.current = null + dispatch({ type: "draftCleared" }) + } + function onSave(e: React.FormEvent) { e.preventDefault() - setFormError(null) + dispatch({ type: "setFormError", message: null }) if (!publication || !activeProvider) return if (!title.trim()) { - setFormError("A headline is required.") + dispatch({ type: "setFormError", message: "A headline is required." }) return } @@ -402,11 +569,9 @@ export function PostEditor() { { isNew, rkey: docRkey, value, coverFile, coverRemoved }, { onSuccess: () => { + clearDraftState() if (isNew) navigate(`/post/${docRkey}`, { replace: true }) - else { - setCoverFile(null) - setCoverRemoved(false) - } + else dispatch({ type: "clearCover" }) }, }, ) @@ -416,7 +581,10 @@ export function PostEditor() { if (!rkey) return if (!window.confirm("Delete this post? This cannot be undone.")) return deleteDocument(rkey, { - onSuccess: () => navigate("/", { replace: true }), + onSuccess: () => { + clearDraftState() + navigate("/", { replace: true }) + }, }) } @@ -498,6 +666,19 @@ export function PostEditor() { // markpub can't reference image blobs, so in-post upload is disabled there. const canUploadImages = !!activeProvider?.supportsImages + // Save-status pill: make it unambiguous whether the visible text is only in + // this browser ("Draft saved locally") or actually written to the PDS. + const statusKind = saving ? "saving" : isDirty ? "local" : "saved" + const statusText = saving + ? "Saving…" + : isDirty + ? savedAt + ? `Draft saved locally · ${relativeTime(savedAt)}` + : "Editing…" + : existing + ? "Saved to your PDS" + : null + return (
@@ -529,7 +710,9 @@ export function PostEditor() { setSelectedProviderId(e.target.value)} + onChange={(e) => + dispatch({ type: "selectProvider", id: e.target.value }) + } title="Richtext format to save this post in" > {providers.map((p) => ( @@ -553,6 +738,11 @@ export function PostEditor() { ))} )} + {statusText && ( + + {statusText} + + )}
} + {restoredAt !== null && ( +
+

Restored unsaved changes

+

+ We recovered a local draft from {relativeTime(restoredAt)}.{" "} + {stale && + "The published version has changed since — saving will overwrite it. "} + It’s only stored in this browser until you{" "} + {isNew ? "publish" : "save"}.{" "} + +

+
+ )} + {lost.length > 0 && (

Some content can’t be edited here

@@ -595,7 +805,7 @@ export function PostEditor() { }} placeholder="Headline" value={title} - onChange={(e) => setTitle(e.target.value)} + onChange={(e) => setField("title", e.target.value)} />
@@ -607,7 +817,7 @@ export function PostEditor() { setDescription(e.target.value)} + onChange={(e) => setField("description", e.target.value)} />
@@ -646,13 +856,15 @@ export function PostEditor() { onChange={(e) => { const f = e.target.files?.[0] ?? null if (f && f.size > 1_000_000) { - setFormError("Cover image must be under 1MB.") + dispatch({ + type: "setFormError", + message: "Cover image must be under 1MB.", + }) e.target.value = "" return } - setFormError(null) - setCoverRemoved(false) - setCoverFile(f) + dispatch({ type: "setFormError", message: null }) + dispatch({ type: "pickCover", file: f }) }} /> {coverPreviewUrl && ( @@ -660,10 +872,7 @@ export function PostEditor() { type="button" className="btn btn--ghost" style={{ alignSelf: "flex-start" }} - onClick={() => { - setCoverFile(null) - setCoverRemoved(true) - }} + onClick={() => dispatch({ type: "removeCover" })} > Remove cover @@ -740,7 +949,7 @@ export function PostEditor() { value={body} height="100%" extensions={cmExtensions} - onChange={setBody} + onChange={(value) => setField("body", value)} onCreateEditor={(view) => { viewRef.current = view }} @@ -781,7 +990,7 @@ export function PostEditor() { onChange={(e) => { // A manual edit wins over the inferred sibling default. pathSeededRef.current = true - setPathTemplate(e.target.value) + setField("pathTemplate", e.target.value) }} placeholder={DEFAULT_PATH_TEMPLATE} spellCheck={false} diff --git a/src/routes/postEditorReducer.ts b/src/routes/postEditorReducer.ts new file mode 100644 index 0000000..8199fa1 --- /dev/null +++ b/src/routes/postEditorReducer.ts @@ -0,0 +1,141 @@ +/** + * Reducer for the post editor's mutable form state. Consolidates what used to be + * a dozen `useState`s into one object with named transitions — notably the + * seed/restore/revert flows that each used to be five-or-more `setState` calls. + * + * Refs (CodeMirror view, uploaded-image map, seeding guards) stay outside this: + * they're imperative handles, not render state. + */ + +import type { PostDraft } from "../lib/drafts.ts" +import { DEFAULT_PATH_TEMPLATE } from "../lib/repo.ts" + +/** The text inputs, seeded together from a record or a draft. */ +export interface EditorFields { + title: string + description: string + tags: string + pathTemplate: string + body: string +} + +export interface EditorState extends EditorFields { + /** A freshly-picked cover file to upload, or null. */ + coverFile: File | null + /** Drop the existing cover image on save. */ + coverRemoved: boolean + /** For a new post, the user-chosen target publication (rkey). */ + selectedPubRkey: string | null + /** For a new post, the user-chosen richtext format. */ + selectedProviderId: string | null + /** Synchronous form-validation error (save/delete errors come from mutations). */ + formError: string | null + /** epoch ms the working state was last persisted locally; null when clean. */ + savedAt: number | null + /** non-null shows the "restored a local draft" banner. */ + restoredAt: number | null + /** the server record changed since the restored draft was based on it. */ + stale: boolean +} + +export type EditorAction = + | { type: "set"; field: keyof EditorFields; value: string } + | { type: "selectPublication"; rkey: string } + | { type: "selectProvider"; id: string } + | { type: "setFormError"; message: string | null } + | { type: "pickCover"; file: File | null } + | { type: "removeCover" } + // After a save: keep the (now-committed) cover, drop the pending edit. + | { type: "clearCover" } + // Seed text inputs from the server record (no draft restored). + | { type: "seed"; fields: EditorFields } + // Adopt a richer body when a list-cache placeholder upgrades to the full load. + | { type: "adoptBody"; body: string } + // Append text to the body (used when an image is inserted with no live editor). + | { type: "appendBody"; text: string } + // Restore a local draft over the server values. + | { type: "restoreDraft"; draft: PostDraft; stale: boolean } + // Revert the inputs (to server values or empty) and forget the draft status. + | { type: "revert"; fields: EditorFields } + | { type: "markSaved"; at: number } + | { type: "markClean" } + // The post was written to / removed from the PDS; clear all draft status. + | { type: "draftCleared" } + +export function initEditorState(selectedPubRkey: string | null): EditorState { + return { + title: "", + description: "", + tags: "", + pathTemplate: DEFAULT_PATH_TEMPLATE, + body: "", + coverFile: null, + coverRemoved: false, + selectedPubRkey, + selectedProviderId: null, + formError: null, + savedAt: null, + restoredAt: null, + stale: false, + } +} + +export function editorReducer( + state: EditorState, + action: EditorAction, +): EditorState { + switch (action.type) { + case "set": + return { ...state, [action.field]: action.value } + case "selectPublication": + return { ...state, selectedPubRkey: action.rkey } + case "selectProvider": + return { ...state, selectedProviderId: action.id } + case "setFormError": + return { ...state, formError: action.message } + case "pickCover": + return { ...state, coverFile: action.file, coverRemoved: false } + case "removeCover": + return { ...state, coverFile: null, coverRemoved: true } + case "clearCover": + return { ...state, coverFile: null, coverRemoved: false } + case "seed": + return { ...state, ...action.fields } + case "adoptBody": + return { ...state, body: action.body } + case "appendBody": + return { + ...state, + body: state.body ? `${state.body}\n\n${action.text}` : action.text, + } + case "restoreDraft": { + const d = action.draft + return { + ...state, + title: d.title, + description: d.description, + tags: d.tags, + pathTemplate: d.pathTemplate, + body: d.body, + selectedProviderId: d.providerId ?? state.selectedProviderId, + savedAt: d.savedAt, + restoredAt: d.savedAt, + stale: action.stale, + } + } + case "revert": + return { + ...state, + ...action.fields, + savedAt: null, + restoredAt: null, + stale: false, + } + case "markSaved": + return { ...state, savedAt: action.at } + case "markClean": + return state.savedAt === null ? state : { ...state, savedAt: null } + case "draftCleared": + return { ...state, savedAt: null, restoredAt: null, stale: false } + } +} diff --git a/src/styles/global.css b/src/styles/global.css index 8924895..e49f7c0 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -368,6 +368,47 @@ select { font-size: 0.85em; } +/* Inline text button (e.g. "Discard draft" inside an admonition). */ +.link-button { + border: none; + background: none; + padding: 0; + font: inherit; + color: var(--accent); + text-decoration: underline; + cursor: pointer; +} +.link-button:hover { + text-decoration: none; +} + +/* ---- Draft save status ---- */ +.save-status { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-faint); + white-space: nowrap; +} +.save-status::before { + font-size: 0.7em; +} +/* Local-only: printer's-red dot — the "not yet on your PDS" warning state. */ +.save-status--local { + color: var(--ink-soft); +} +.save-status--local::before { + content: "●"; + color: var(--accent); +} +/* Committed to the PDS. */ +.save-status--saved::before { + content: "✓"; +} + .divider-rule { border-top: 1px solid var(--rule); } From b178f5ca38952dac1454c4b506b33dae0c8b28a2 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 31 May 2026 13:10:29 +0300 Subject: [PATCH 2/5] fix: don't wipe a saved draft before it restores on refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit on a fresh load the persist effect ran before the record finished loading, when the form was still empty — so isDirty was false and the !isDirty branch cleared the draft from localStorage a beat before the seeding effect could restore it. gate persistence behind a `hydrated` flag set once the initial seed/restore has run. Co-Authored-By: Claude Opus 4.8 --- src/routes/PostEditor.tsx | 16 ++++++++++++---- src/routes/postEditorReducer.ts | 12 ++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/routes/PostEditor.tsx b/src/routes/PostEditor.tsx index 035b023..1da0a26 100644 --- a/src/routes/PostEditor.tsx +++ b/src/routes/PostEditor.tsx @@ -128,6 +128,7 @@ export function PostEditor() { savedAt, restoredAt, stale, + hydrated, } = state const setField = (field: keyof EditorFields, value: string) => dispatch({ type: "set", field, value }) @@ -285,6 +286,7 @@ export function PostEditor() { }, }) } + dispatch({ type: "hydrated" }) } else if ( md !== seededBodyRef.current && body.trim() === seededBodyRef.current @@ -311,9 +313,11 @@ export function PostEditor() { if (!isNew || newSeededRef.current || !did || !publication) return newSeededRef.current = true const draft = loadDraft(draftKey(did, { newPub: publication.rkey })) - if (!draft) return - uploadedImagesRef.current = deserializeImages(draft.images) - dispatch({ type: "restoreDraft", draft, stale: false }) + if (draft) { + uploadedImagesRef.current = deserializeImages(draft.images) + dispatch({ type: "restoreDraft", draft, stale: false }) + } + dispatch({ type: "hydrated" }) }, [isNew, did, publication]) const cmExtensions = useMemo( @@ -397,7 +401,10 @@ export function PostEditor() { const baseCid = editableDoc?.entry.cid ?? null const flushRef = useRef<(() => void) | null>(null) useEffect(() => { - if (!currentKey) return + // Stay paused until the record's loaded and seeded/restored — otherwise the + // empty initial form reads as "clean" and would clear a saved draft before + // it gets restored on refresh. + if (!hydrated || !currentKey) return if (!isDirty) { flushRef.current = null clearDraft(currentKey) @@ -424,6 +431,7 @@ export function PostEditor() { const t = setTimeout(write, 600) return () => clearTimeout(t) }, [ + hydrated, currentKey, isDirty, title, diff --git a/src/routes/postEditorReducer.ts b/src/routes/postEditorReducer.ts index 8199fa1..3d1bd86 100644 --- a/src/routes/postEditorReducer.ts +++ b/src/routes/postEditorReducer.ts @@ -36,6 +36,13 @@ export interface EditorState extends EditorFields { restoredAt: number | null /** the server record changed since the restored draft was based on it. */ stale: boolean + /** + * Whether the initial seed/restore has run. Draft persistence stays paused + * until this flips true, so the empty initial form (while the record is still + * loading) can't be mistaken for "clean" and wipe a saved draft before it's + * had a chance to restore. + */ + hydrated: boolean } export type EditorAction = @@ -47,6 +54,8 @@ export type EditorAction = | { type: "removeCover" } // After a save: keep the (now-committed) cover, drop the pending edit. | { type: "clearCover" } + // Initial seed/restore has run — draft persistence may begin. + | { type: "hydrated" } // Seed text inputs from the server record (no draft restored). | { type: "seed"; fields: EditorFields } // Adopt a richer body when a list-cache placeholder upgrades to the full load. @@ -77,6 +86,7 @@ export function initEditorState(selectedPubRkey: string | null): EditorState { savedAt: null, restoredAt: null, stale: false, + hydrated: false, } } @@ -99,6 +109,8 @@ export function editorReducer( return { ...state, coverFile: null, coverRemoved: true } case "clearCover": return { ...state, coverFile: null, coverRemoved: false } + case "hydrated": + return state.hydrated ? state : { ...state, hydrated: true } case "seed": return { ...state, ...action.fields } case "adoptBody": From 799aebdef552e49e694ff7a0fc01fb77fffdfdbc Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 31 May 2026 13:12:04 +0300 Subject: [PATCH 3/5] add a discard button to the editor toolbar surfaces the existing discardDraft logic as an always-available control, shown whenever there are unsaved changes. confirms first, then clears the local draft and reverts to the published version (or empties a new post). the restore banner's "discard draft" link now routes through the same confirm. Co-Authored-By: Claude Opus 4.8 --- src/routes/PostEditor.tsx | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/routes/PostEditor.tsx b/src/routes/PostEditor.tsx index 1da0a26..e6bdec6 100644 --- a/src/routes/PostEditor.tsx +++ b/src/routes/PostEditor.tsx @@ -522,6 +522,15 @@ export function PostEditor() { dispatch({ type: "revert", fields }) } + /** Confirm, then discard — used by the toolbar button and the restore banner. */ + function onDiscard() { + const message = existing + ? "Discard your unsaved changes and revert to the published version?" + : "Discard this draft? Everything you’ve written here will be lost." + if (!window.confirm(message)) return + discardDraft() + } + /** Reset draft state after the post is written to (or removed from) the PDS. */ function clearDraftState() { if (currentKey) clearDraft(currentKey) @@ -751,6 +760,21 @@ export function PostEditor() { {statusText} )} + {isDirty && ( + + )}

From 5756d8a454a47a342b5e0133c1cf27f26db844bd Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 31 May 2026 13:37:00 +0300 Subject: [PATCH 4/5] fix: clear dirty state + draft on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isDirty diffs the body against seededBodyRef, a baseline set at load and never moved on save — so after saving an existing post the body always read as dirty, and the persist effect kept re-writing the draft we'd just cleared. on save we now rebaseline seededBodyRef to the saved body and bump a baselineTick so the isDirty memo (which can't depend on a ref) recomputes — clearing the dirty state right away for body edits. a debounced write also bails if the draft was cleared underneath it (flushRef nulled), so a pending write can't resurrect a just-saved draft. metadata-only edits settle on the background refetch. Co-Authored-By: Claude Opus 4.8 --- src/routes/PostEditor.tsx | 17 ++++++++++++++++- src/routes/postEditorReducer.ts | 12 ++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/routes/PostEditor.tsx b/src/routes/PostEditor.tsx index e6bdec6..f1d8e5d 100644 --- a/src/routes/PostEditor.tsx +++ b/src/routes/PostEditor.tsx @@ -129,6 +129,7 @@ export function PostEditor() { restoredAt, stale, hydrated, + baselineTick, } = state const setField = (field: keyof EditorFields, value: string) => dispatch({ type: "set", field, value }) @@ -391,6 +392,8 @@ export function PostEditor() { coverFile, coverRemoved, siblingPathTemplate, + // seededBodyRef is a ref the memo can't watch; recompute when it's moved. + baselineTick, ]) // Mirror the editor's working state into localStorage so nothing typed is lost @@ -424,6 +427,10 @@ export function PostEditor() { savedAt: at, } const write = () => { + // Bail if a newer write superseded this one, or a save/discard cleared the + // draft underneath us (clearDraftState nulls flushRef) — otherwise a + // debounced write could resurrect a draft we just cleared on save. + if (flushRef.current !== write) return saveDraft(currentKey, payload) dispatch({ type: "markSaved", at }) } @@ -588,7 +595,15 @@ export function PostEditor() { onSuccess: () => { clearDraftState() if (isNew) navigate(`/post/${docRkey}`, { replace: true }) - else dispatch({ type: "clearCover" }) + else { + // Rebaseline the body to what we just saved so isDirty clears + // immediately — the document refetch (which would otherwise update + // this) is async, and round-tripping the body back from the stored + // format isn't guaranteed to be identical anyway. + seededBodyRef.current = cleanBody + dispatch({ type: "rebaseline" }) + dispatch({ type: "clearCover" }) + } }, }, ) diff --git a/src/routes/postEditorReducer.ts b/src/routes/postEditorReducer.ts index 3d1bd86..20e1813 100644 --- a/src/routes/postEditorReducer.ts +++ b/src/routes/postEditorReducer.ts @@ -43,6 +43,13 @@ export interface EditorState extends EditorFields { * had a chance to restore. */ hydrated: boolean + /** + * Bumped whenever the body's clean baseline (`seededBodyRef`) is moved without + * the body itself changing — i.e. on save. The `isDirty` memo reads that ref, + * which it can't list as a dependency, so this gives it something to recompute + * on. Without it, saving wouldn't clear the dirty state. + */ + baselineTick: number } export type EditorAction = @@ -56,6 +63,8 @@ export type EditorAction = | { type: "clearCover" } // Initial seed/restore has run — draft persistence may begin. | { type: "hydrated" } + // The body's clean baseline moved (on save) — force an isDirty recompute. + | { type: "rebaseline" } // Seed text inputs from the server record (no draft restored). | { type: "seed"; fields: EditorFields } // Adopt a richer body when a list-cache placeholder upgrades to the full load. @@ -87,6 +96,7 @@ export function initEditorState(selectedPubRkey: string | null): EditorState { restoredAt: null, stale: false, hydrated: false, + baselineTick: 0, } } @@ -111,6 +121,8 @@ export function editorReducer( return { ...state, coverFile: null, coverRemoved: false } case "hydrated": return state.hydrated ? state : { ...state, hydrated: true } + case "rebaseline": + return { ...state, baselineTick: state.baselineTick + 1 } case "seed": return { ...state, ...action.fields } case "adoptBody": From cb7a0c8a255bf45dab19491fef9ca06fb43e2bbc Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Sun, 31 May 2026 14:32:50 +0300 Subject: [PATCH 5/5] add integration tests for editor draft persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mounts the real PostEditor with the real query hooks, reducer, drafts module and jsdom localStorage; mocks only the network layer (repo.ts), auth, CodeMirror and analytics. covers the regressions we hit: draft persists on type, restores after a reload, saving clears the dirty state + draft (and doesn't resurrect it), discard reverts, and new-post drafts key by publication. verified the suite has teeth — backing out the save-rebaseline fix or the hydration gate each fails its corresponding test. sets up vitest jsdom env (per-file), testing-library, and a plausible stub alias (the package ships only a `module` field the test resolver can't load). Co-Authored-By: Claude Opus 4.8 --- package.json | 4 + pnpm-lock.yaml | 505 ++++++++++++++++++++++++++++++++- src/routes/PostEditor.test.tsx | 288 +++++++++++++++++++ src/test/plausible-stub.ts | 5 + src/test/setup.ts | 3 + vite.config.ts | 15 +- 6 files changed, 817 insertions(+), 3 deletions(-) create mode 100644 src/routes/PostEditor.test.tsx create mode 100644 src/test/plausible-stub.ts create mode 100644 src/test/setup.ts diff --git a/package.json b/package.json index 8ce9c6c..e4f0c6b 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,9 @@ "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@resvg/resvg-js": "^2.6.2", "@rolldown/plugin-babel": "^0.2.3", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/babel__core": "^7.20.5", "@types/mdast": "^4.0.4", "@types/node": "^25.9.1", @@ -54,6 +57,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "babel-plugin-react-compiler": "^1.0.0", + "jsdom": "^29.1.1", "prettier": "^3.8.3", "satori": "^0.26.0", "typescript": "^6.0.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4cddf8b..d9a8779 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,15 @@ importers: '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.2)(vite@8.0.14(@types/node@25.9.1)) + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 @@ -93,6 +102,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@1.8.0) prettier: specifier: ^3.8.3 version: 3.8.3 @@ -107,10 +119,28 @@ importers: version: 8.0.14(@types/node@25.9.1) vitest: specifier: ^4.1.7 - version: 4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)) + version: 4.1.7(@types/node@25.9.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.14(@types/node@25.9.1)) packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@atproto-labs/did-resolver@0.3.1': resolution: {integrity: sha512-5AnmT38iqoKC3irmJqrmtoj/uEFYPbPZKAms4FMuBuhckmKd5Q8GuVwJoYsPk/+ZdKgAHNJfgnefWCo0UsE47Q==} engines: {node: '>=22'} @@ -307,6 +337,10 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@codemirror/autocomplete@6.20.2': resolution: {integrity: sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==} @@ -400,6 +434,42 @@ packages: '@codemirror/view@6.43.0': resolution: {integrity: sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==} + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.1': + resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4': + resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -409,6 +479,15 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fontsource/ibm-plex-sans@5.2.8': resolution: {integrity: sha512-eztSXjDhPhcpxNIiGTgMebdLP9qS4rWkysuE1V7c+DjOR0qiezaiDaTwQE7bTnG5HxAY/8M43XKDvs3cYq6ZYQ==} @@ -734,12 +813,44 @@ packages: peerDependencies: react: ^18 || ^19 + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.1': + resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@ts-morph/common@0.28.1': resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -864,14 +975,29 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -902,6 +1028,9 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@5.0.6: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} @@ -989,9 +1118,20 @@ packages: css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1001,6 +1141,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1015,6 +1158,12 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + electron-to-chromium@1.5.363: resolution: {integrity: sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==} @@ -1025,6 +1174,10 @@ packages: emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -1103,12 +1256,20 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inline-style-parser@0.2.7: resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} @@ -1128,6 +1289,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + iso-datestring-validator@2.2.2: resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} @@ -1137,6 +1301,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1230,9 +1403,17 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1284,6 +1465,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -1368,6 +1552,10 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1403,6 +1591,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1438,6 +1629,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + process-warning@3.0.0: resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} @@ -1448,6 +1643,10 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1462,6 +1661,9 @@ packages: peerDependencies: react: ^19.2.6 + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -1490,6 +1692,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -1505,6 +1711,10 @@ packages: remark@15.0.1: resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rolldown@1.0.2: resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1521,6 +1731,10 @@ packages: resolution: {integrity: sha512-tkMFrfIs3l2mQ2JEcyW0ADTy3zGggFRFzi6Ef8YozQSFsFKEqaSO1Y8F9wJg4//PJGQauMalHGTUEkPrFwhVPA==} engines: {node: '>=16'} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -1576,6 +1790,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-markdown@6.0.0: resolution: {integrity: sha512-mSa8FtUoX3ExJYDkjPUTC14xaBAn4Ik5GPQD45G5E2egAmeV3kHgVSTfIoSDggbF6Pk9stahVgqsLCNExv6jHw==} @@ -1588,6 +1806,9 @@ packages: style-to-object@1.0.14: resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + thread-stream@2.7.0: resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} @@ -1609,6 +1830,21 @@ packages: resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} + tldts-core@7.4.2: + resolution: {integrity: sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==} + + tldts@7.4.2: + resolution: {integrity: sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==} + hasBin: true + + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -1632,6 +1868,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@7.26.0: + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} + engines: {node: '>=20.18.1'} + unicode-segmenter@0.14.5: resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} @@ -1758,6 +1998,22 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1767,6 +2023,13 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -1793,6 +2056,28 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@atproto-labs/did-resolver@0.3.1': dependencies: '@atproto-labs/fetch': 0.3.0 @@ -2112,6 +2397,10 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@codemirror/autocomplete@6.20.2': dependencies: '@codemirror/language': 6.12.3 @@ -2370,6 +2659,30 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -2386,6 +2699,10 @@ snapshots: tslib: 2.8.1 optional: true + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': + optionalDependencies: + '@noble/hashes': 1.8.0 + '@fontsource/ibm-plex-sans@5.2.8': {} '@fontsource/newsreader@5.2.10': {} @@ -2657,6 +2974,40 @@ snapshots: '@tanstack/query-core': 5.100.14 react: 19.2.6 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + optionalDependencies: + '@types/react': 19.2.15 + '@types/react-dom': 19.2.3(@types/react@19.2.15) + + '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + '@ts-morph/common@0.28.1': dependencies: minimatch: 10.2.5 @@ -2668,6 +3019,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.7 @@ -2814,10 +3167,20 @@ snapshots: dependencies: event-target-shim: 5.0.1 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + assertion-error@2.0.1: {} atomic-sleep@1.0.0: {} @@ -2836,6 +3199,10 @@ snapshots: baseline-browser-mapping@2.10.32: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@5.0.6: dependencies: balanced-match: 4.0.4 @@ -2915,12 +3282,28 @@ snapshots: css-color-keywords: 1.0.0 postcss-value-parser: 4.2.0 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + csstype@3.2.3: {} + data-urls@7.0.0(@noble/hashes@1.8.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -2933,12 +3316,18 @@ snapshots: dependencies: dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + electron-to-chromium@1.5.363: {} emoji-regex-xs@2.0.1: {} emoji-regex@10.6.0: {} + entities@8.0.0: {} + es-module-lexer@2.1.0: {} escalade@3.2.0: {} @@ -3004,10 +3393,18 @@ snapshots: hex-rgb@4.3.0: {} + html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + transitivePeerDependencies: + - '@noble/hashes' + html-url-attributes@3.0.1: {} ieee754@1.2.1: {} + indent-string@4.0.0: {} + inline-style-parser@0.2.7: {} is-alphabetical@2.0.1: {} @@ -3023,12 +3420,40 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + iso-datestring-validator@2.2.2: {} jose@5.10.0: {} js-tokens@4.0.0: {} + jsdom@29.1.1(@noble/hashes@1.8.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@1.8.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.26.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@1.8.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json5@2.2.3: {} @@ -3091,10 +3516,14 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3254,6 +3683,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -3445,6 +3876,8 @@ snapshots: transitivePeerDependencies: - supports-color + min-indent@1.0.1: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -3478,6 +3911,10 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} pathe@2.0.3: {} @@ -3517,12 +3954,20 @@ snapshots: prettier@3.8.3: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + process-warning@3.0.0: {} process@0.11.10: {} property-information@7.1.0: {} + punycode@2.3.1: {} + quick-format-unescaped@4.0.4: {} react-colorful@5.7.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): @@ -3535,6 +3980,8 @@ snapshots: react: 19.2.6 scheduler: 0.27.0 + react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.2.15)(react@19.2.6): dependencies: '@types/hast': 3.0.4 @@ -3573,6 +4020,11 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -3616,6 +4068,8 @@ snapshots: transitivePeerDependencies: - supports-color + require-from-string@2.0.2: {} + rolldown@1.0.2: dependencies: '@oxc-project/types': 0.132.0 @@ -3655,6 +4109,10 @@ snapshots: postcss-value-parser: 4.2.0 yoga-layout: 3.2.1 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -3700,6 +4158,10 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-markdown@6.0.0: dependencies: '@types/mdast': 4.0.4 @@ -3714,6 +4176,8 @@ snapshots: dependencies: inline-style-parser: 0.2.7 + symbol-tree@3.2.4: {} + thread-stream@2.7.0: dependencies: real-require: 0.2.0 @@ -3731,6 +4195,20 @@ snapshots: tinyrainbow@3.1.0: {} + tldts-core@7.4.2: {} + + tldts@7.4.2: + dependencies: + tldts-core: 7.4.2 + + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.2 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -3750,6 +4228,8 @@ snapshots: undici-types@7.24.6: {} + undici@7.26.0: {} + unicode-segmenter@0.14.5: {} unicode-trie@2.0.0: @@ -3819,7 +4299,7 @@ snapshots: '@types/node': 25.9.1 fsevents: 2.3.3 - vitest@4.1.7(@types/node@25.9.1)(vite@8.0.14(@types/node@25.9.1)): + vitest@4.1.7(@types/node@25.9.1)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.0.14(@types/node@25.9.1)): dependencies: '@vitest/expect': 4.1.7 '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@25.9.1)) @@ -3843,11 +4323,28 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.1 + jsdom: 29.1.1(@noble/hashes@1.8.0) transitivePeerDependencies: - msw w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@1.8.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@1.8.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3859,6 +4356,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/routes/PostEditor.test.tsx b/src/routes/PostEditor.test.tsx new file mode 100644 index 0000000..816ee42 --- /dev/null +++ b/src/routes/PostEditor.test.tsx @@ -0,0 +1,288 @@ +// @vitest-environment jsdom +/** + * Integration tests for the post editor's draft-persistence behaviour. These + * mount the real with the real query hooks, reducer, drafts module + * and (jsdom) localStorage — only the network layer (repo.ts), auth, CodeMirror + * and analytics are mocked. That's the level the draft bugs lived at: + * + * - a saved draft being wiped by the load race before it could restore, + * - the dirty state (and draft) not clearing after a save, + * - drafts not restoring on reload. + */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react" +import { MemoryRouter, Route, Routes } from "react-router" +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + type Mock, +} from "vitest" +import { draftKey } from "../lib/drafts.ts" +import { buildMarkpubContent } from "../lib/markpub.ts" +import { + createDocument, + deleteDocument, + getDocument, + listDocuments, + listPublications, + putDocument, +} from "../lib/repo.ts" +import { PostEditor } from "./PostEditor.tsx" + +const TEST_DID = "did:plc:test" +const PUB_RKEY = "pub1" +const PUB = { + uri: `at://${TEST_DID}/site.standard.publication/${PUB_RKEY}`, + cid: "cid-pub1", + rkey: PUB_RKEY, + value: { name: "Test Blog", url: "https://blog.test" }, +} + +// --- mocks --- + +vi.mock("../auth/AuthProvider.tsx", () => ({ + useAuth: () => ({ + did: TEST_DID, + client: {}, + status: "signed-in", + profile: null, + signOut: vi.fn(), + }), +})) + +// CodeMirror needs DOM measurement that jsdom can't do; a plain textarea honours +// the same value/onChange contract the editor relies on. +vi.mock("@uiw/react-codemirror", () => ({ + EditorView: { lineWrapping: {} }, + default: ({ + value, + onChange, + }: { + value: string + onChange?: (v: string) => void + }) => ( +