From e99b4afbf9cbcc1777265072f102dda061a86716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 5 Sep 2026 12:04:34 +0100 Subject: [PATCH] the vendoring machinery: one command reproduces the whole state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/vendor.sh clones upstream, rewrites @/ imports to relative paths, and applies scripts/apply-host.py. web/host.ts is ours and gets placed into the vendored tree, because upstream declined it. a script rather than a .patch, and that is not a preference. between cfde698 and 5c83ac6 — one day — upstream changed an import line, re-indented the component from 6 spaces to 8, and added an editAccess guard. a context diff fails on all three, none of which concern us. so every anchor matches structure with flexible whitespace and re-indents to whatever it finds, and a missing anchor stops the run loudly. all three prompt-export paths are wired, which is what upstream said a complete version needs. the other two clipboard writes are ShareMenu's share-link and ask-an-AI blurb, and the reason they are excluded is written down so the count does not read as the same oversight twice. Co-Authored-By: Claude Fable 5 --- scripts/apply-host.py | 240 ++++++++++++++++++++++++++ scripts/vendor.sh | 4 + web/host.ts | 46 +++++ web/vendor/Canvas.tsx | 50 +++--- web/vendor/components/Inspector.tsx | 5 + web/vendor/components/PromptPanel.tsx | 5 + web/vendor/lib/host.ts | 46 +++++ 7 files changed, 373 insertions(+), 23 deletions(-) create mode 100755 scripts/apply-host.py create mode 100644 web/host.ts create mode 100644 web/vendor/lib/host.ts diff --git a/scripts/apply-host.py b/scripts/apply-host.py new file mode 100755 index 0000000..09eb1a6 --- /dev/null +++ b/scripts/apply-host.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +""" +Wire the host adapter into the vendored editor. + +Run by `vendor.sh` immediately after the import rewrite, so `web/vendor/` becomes a faithful copy of +upstream plus exactly this and nothing else. + +── Why a script and not a .patch ── + +Upstream moves fast and reformats. Between `cfde698` and `5c83ac6`, one day apart, the i18n import +line changed AND the whole component was re-indented from 6 spaces to 8 AND an `editAccess` guard +appeared. A context diff would have failed on all three, none of which have anything to do with us. + +So every anchor here matches on STRUCTURE with flexible whitespace, and re-indents its replacement to +whatever it found. When an anchor genuinely disappears the script stops with the file and the thing +it wanted — loudly, because a silent mis-wire is the failure that matters. + +What it wires is what upstream said a complete version needs (lnkiai/m3e-canvas#56): the host hooks, +in ALL THREE export paths rather than one. + +── Three, and not the other two ── + +A scan for `clipboard.writeText` finds FIVE call sites. Two of them are in `ShareMenu.tsx`, which +upstream added after the review, and they are deliberately NOT wired: + + copyAsk an "ask an AI" blurb containing a guide URL and the user's one-line idea + copyLink a share link to the design + +Neither is the generated prompt, so neither belongs behind `onExport`. This is written down because +the count looks wrong at a glance, and the previous round of this work was rejected for wiring one +path out of three — the next person to run that scan should not have to re-derive which is which. +""" +import re +import sys +from pathlib import Path + +VENDOR = Path(__file__).resolve().parent.parent / "web" / "vendor" + + +def fail(what: str, where: str) -> None: + raise SystemExit( + f"\n Could not find {what} in {where}.\n" + f" Upstream has moved underneath this script. Read the file, re-anchor, and re-run.\n" + f" This failing is the point — it is how a silent mis-wire is avoided.\n" + ) + + +def reindent(block: str, pad: str) -> str: + """Re-indent a replacement to sit at the indentation actually found in the file.""" + lines = block.split("\n") + return "\n".join(pad + l if l.strip() else l for l in lines) + + +def sub_once(src: str, pattern: str, build, what: str, where: str, flags=0) -> str: + match = re.search(pattern, src, flags) + if not match: + fail(what, where) + pad = re.match(r"[ \t]*", match.group(0)).group(0) + return src[: match.start()] + build(match, pad) + src[match.end() :] + + +def add_import(src: str, spec: str, where: str) -> str: + lines = src.split("\n") + idx = [i for i, l in enumerate(lines) if l.startswith("import ")] + if not idx: + fail("any import statement", where) + lines.insert(idx[-1] + 1, spec) + return "\n".join(lines) + + +# ── Canvas.tsx ──────────────────────────────────────────────────────────────────────────────────── + +def canvas(src: str, name: str) -> str: + src = add_import(src, 'import { useHost } from "./lib/host";', name) + + src = sub_once( + src, + r"[ \t]*export default function Page\(\) \{", + lambda m, pad: m.group(0) + + f"\n{pad} // Optional host hooks; every default is the standalone behaviour. See lib/host.ts." + + f"\n{pad} const host = useHost();", + "the component entry point", + name, + ) + + src = sub_once( + src, + r"([ \t]*)const d = localStorage\.getItem\(DOC_KEY\);\n[ \t]*if \(d\) \{", + lambda m, pad: reindent( + '// Branch on whether the host SUPPLIED a loader, not on what it returned: a host\n' + '// returning null means "no saved document", which must start fresh rather than\n' + "// fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56.\n" + "const hosted = host.loadDoc ? host.loadDoc() : null;\n" + "const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY);\n" + "if (hosted) {\n" + " hadDocRef.current = true;\n" + " applyDoc(hosted, false);\n" + "} else if (d) {", + pad, + ).lstrip(), + "the document read", + name, + ) + + src = sub_once( + src, + r"([ \t]*)const u = localStorage\.getItem\(UI_KEY\);\n[ \t]*if \(u\) \{\n[ \t]*const ui = JSON\.parse\(u\);", + lambda m, pad: reindent( + "const stored = host.loadUi ? null : localStorage.getItem(UI_KEY);\n" + "const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null;\n" + "if (ui) {", + pad, + ).lstrip(), + "the UI read", + name, + ) + + src = sub_once( + src, + r"([ \t]*)localStorage\.setItem\(\s*DOC_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);", + lambda m, pad: reindent( + f"const payload = {m.group(2)};\n" + "if (host.saveDoc) host.saveDoc(payload as Partial);\n" + "else localStorage.setItem(DOC_KEY, JSON.stringify(payload));", + pad, + ).lstrip(), + "the document write", + name, + re.S, + ) + + src = sub_once( + src, + r"([ \t]*)localStorage\.setItem\(\s*UI_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);", + lambda m, pad: reindent( + "const ui = {%s};\n" % " ".join(m.group(2)[1:-1].split()) + + "if (host.saveUi) host.saveUi(ui);\n" + "else localStorage.setItem(UI_KEY, JSON.stringify(ui));", + pad, + ).lstrip(), + "the UI write", + name, + re.S, + ) + + return sub_once( + src, + r"([ \t]*)await navigator\.clipboard\.writeText\(effectivePrompt\(doc, widths, lang\)\);", + lambda m, pad: reindent( + "const text = effectivePrompt(doc, widths, lang);\n" + "// The hook's own failure is isolated: a broken host must not also break copying.\n" + "let handled = false;\n" + "try { handled = host.onExport?.(text, doc) === true; } catch {}\n" + "if (handled) return;\n" + "await navigator.clipboard.writeText(text);", + pad, + ).lstrip(), + "the toolbar export (1 of 3)", + name, + ) + + +# ── the other two export paths ──────────────────────────────────────────────────────────────────── + +def prompt_panel(src: str, name: str) -> str: + src = add_import(src, 'import { useHost } from "../lib/host";', name) + src = sub_once( + src, + r"[ \t]*const \[copied, setCopied\] = useState\(false\);", + lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0), + "the copied state", + name, + ) + return sub_once( + src, + r"([ \t]*)await navigator\.clipboard\.writeText\(text\);", + lambda m, pad: reindent( + "let handled = false;\n" + "try { handled = host.onExport?.(text, null) === true; } catch {}\n" + "if (handled) { setCopied(true); return; }\n" + "await navigator.clipboard.writeText(text);", + pad, + ).lstrip(), + "the copy call (2 of 3)", + name, + ) + + +def inspector(src: str, name: str) -> str: + src = add_import(src, 'import { useHost } from "../lib/host";', name) + # The hook belongs to whichever component owns `prompt`; it is taken beside the copied state. + src = sub_once( + src, + r"[ \t]*const \[copied, setCopied\] = useState\(false\);", + lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0), + "the copied state", + name, + ) + return sub_once( + src, + r"([ \t]*)await navigator\.clipboard\.writeText\(prompt\);", + lambda m, pad: reindent( + "let handled = false;\n" + "try { handled = host.onExport?.(prompt, null) === true; } catch {}\n" + "if (handled) { setCopied(true); return; }\n" + "await navigator.clipboard.writeText(prompt);", + pad, + ).lstrip(), + "the export copy call (3 of 3)", + name, + ) + + +def main() -> int: + if not VENDOR.exists(): + raise SystemExit("web/vendor/ is missing — run scripts/vendor.sh first.") + + # `lib/host.ts` is OURS, not upstream's — upstream declined it. The canonical copy is + # `web/host.ts`, edited like any other file we own; this places it where the vendored code's + # imports expect it. Without this step `vendor.sh` leaves those imports dangling, because it + # wipes the directory before copying. + source = VENDOR.parent / "host.ts" + if not source.exists(): + raise SystemExit("web/host.ts is missing — it is the source for the vendored lib/host.ts.") + (VENDOR / "lib" / "host.ts").write_text(source.read_text(encoding="utf-8"), encoding="utf-8") + print(" placed lib/host.ts (ours)") + for rel, fn in ( + ("Canvas.tsx", canvas), + ("components/PromptPanel.tsx", prompt_panel), + ("components/Inspector.tsx", inspector), + ): + path = VENDOR / rel + path.write_text(fn(path.read_text(encoding="utf-8"), rel), encoding="utf-8") + print(f" wired {rel}") + print(" all three export paths wired") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/vendor.sh b/scripts/vendor.sh index ba6d727..7a6a9bb 100755 --- a/scripts/vendor.sh +++ b/scripts/vendor.sh @@ -72,6 +72,10 @@ for root, _, files in os.walk(dest): open(path, "w", encoding="utf-8").write(out) PY +# Officer's own changes to the vendored code. Kept OUT of this script so the copy above is exactly +# upstream, and applied here so one command reproduces the whole state. +python3 "$HERE/scripts/apply-host.py" + cat > "$DEST/PROVENANCE.md" < sendToAgent(text) }}> + * + * + * ``` + */ +export type HostAdapter = { + /** Return the saved document, or null for a fresh one. Omit to read localStorage. */ + loadDoc?: () => Partial | null; + /** Persist the document. Omit to write localStorage. Called on every change, so debounce if remote. */ + saveDoc?: (doc: Partial) => void; + + /** Editor chrome — panel widths, favourites. Separate from the document because it is not the work. */ + loadUi?: () => unknown | null; + saveUi?: (ui: unknown) => void; + + /** + * Where the generated prompt goes. + * + * Return `true` to say the host has handled it and the editor should not also write the clipboard; + * anything else and the clipboard write still happens. That default matters — a host that only + * wants to observe the export does not have to reimplement copying. + */ + onExport?: (prompt: string, doc: Doc) => boolean | void; +}; + +const EMPTY: HostAdapter = {}; + +export const HostContext = createContext(EMPTY); + +export const HostProvider = HostContext.Provider; + +export const useHost = (): HostAdapter => useContext(HostContext); diff --git a/web/vendor/Canvas.tsx b/web/vendor/Canvas.tsx index 7602246..7592436 100644 --- a/web/vendor/Canvas.tsx +++ b/web/vendor/Canvas.tsx @@ -99,6 +99,7 @@ import { ThemeContext, ensureFontLoaded, ensureLangFontLoaded } from "./lib/them import { BottomSheet, MobileActionBar, MobileInspector, MobileLang, MobileSettings } from "./components/Mobile"; import { ConfirmDialog, IconBtn, Segmented } from "./components/ui"; import { Lang, LangContext, SEED_TEXT, getLang, isLang, setGlobalLang, t, translateDefaultFrameName, translateDefaultText } from "./lib/i18n"; +import { useHost } from "./lib/host"; /** the screens while a model drafts: primary, tertiary and primary container, drifting */ const DRAFT_GRADIENT = (p: Palette) => `linear-gradient(120deg, ${p.primaryContainer}, ${p.tertiaryContainer}, ${p.primary}, ${p.secondaryContainer}, ${p.primaryContainer})`; @@ -323,6 +324,8 @@ const LEFT_TABS: { key: LeftTab; icon: string; title: "parts" | "layers" | "colo ]; export default function Page() { + // Optional host hooks; every default is the standalone behaviour. See lib/host.ts. + const host = useHost(); /* ---------- document ---------- */ const [editAccess, setEditAccess] = useState<"checking" | "editable" | "readonly">("checking"); const [groups, setGroups] = useState(seed); @@ -605,8 +608,15 @@ export default function Page() { // React's development double-run would otherwise read back its own first save if (loadedRef.current) return; try { - const d = localStorage.getItem(DOC_KEY); - if (d) { +// Branch on whether the host SUPPLIED a loader, not on what it returned: a host + // returning null means "no saved document", which must start fresh rather than + // fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56. + const hosted = host.loadDoc ? host.loadDoc() : null; + const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY); + if (hosted) { + hadDocRef.current = true; + applyDoc(hosted, false); + } else if (d) { hadDocRef.current = true; applyDoc(JSON.parse(d) as Partial, false); // frame mode is decided by the device (media-query effect), not restored @@ -619,9 +629,9 @@ export default function Page() { else localStorage.removeItem(BEFORE_KEY); } let initialLang: Lang = "ja"; - const u = localStorage.getItem(UI_KEY); - if (u) { - const ui = JSON.parse(u); +const stored = host.loadUi ? null : localStorage.getItem(UI_KEY); + const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null; + if (ui) { if (ui.view) setView(ui.view); if (typeof ui.leftOpen === "boolean") setLeftOpen(ui.leftOpen); if (typeof ui.rightOpen === "boolean") setRightOpen(ui.rightOpen); @@ -732,29 +742,18 @@ export default function Page() { useEffect(() => { if (!loadedRef.current || editAccess !== "editable") return; try { - localStorage.setItem( - DOC_KEY, - JSON.stringify({ groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme }), - ); +const payload = { groups, frames, paletteKey, frame, title, brief, promptEdit, platform: platform ?? undefined, customPalette: customPalette ?? undefined, dynamicColor, theme }; + if (host.saveDoc) host.saveDoc(payload as Partial); + else localStorage.setItem(DOC_KEY, JSON.stringify(payload)); } catch {} }, [editAccess, groups, frames, paletteKey, frame, title, brief, promptEdit, platform, customPalette, dynamicColor, theme]); useEffect(() => { if (!loadedRef.current) return; try { - localStorage.setItem( - UI_KEY, - JSON.stringify({ - view, - leftOpen, - rightOpen, - leftW, - rightW, - favorites, - mode, - lang, - }), - ); +const ui = {view, leftOpen, rightOpen, leftW, rightW, favorites, mode, lang,}; + if (host.saveUi) host.saveUi(ui); + else localStorage.setItem(UI_KEY, JSON.stringify(ui)); } catch {} }, [ view, @@ -3467,7 +3466,12 @@ export default function Page() { onLangSheet={() => setSheet(sheet === "lang" ? null : "lang")} onPrompt={async () => { try { - await navigator.clipboard.writeText(effectivePrompt(doc, widths, lang)); +const text = effectivePrompt(doc, widths, lang); + // The hook's own failure is isolated: a broken host must not also break copying. + let handled = false; + try { handled = host.onExport?.(text, doc) === true; } catch {} + if (handled) return; + await navigator.clipboard.writeText(text); showToast(t("copied", lang), 1400, "check"); } catch {} }} diff --git a/web/vendor/components/Inspector.tsx b/web/vendor/components/Inspector.tsx index 9cd5797..af87f38 100644 --- a/web/vendor/components/Inspector.tsx +++ b/web/vendor/components/Inspector.tsx @@ -42,6 +42,7 @@ import { ButtonRun, CornerIcon, Field, IconBtn, Section, Segmented, SizePresets, import { AiWriteBtn } from "./AiPanel"; import { popHistory } from "../lib/ai"; import { KIND_TEXT, SWIPE_TEXT, TRANSITION_TEXT, t, useLang } from "../lib/i18n"; +import { useHost } from "../lib/host"; /** A text field for a web address: what is typed stays in the box, and only a complete * http(s) address (or an emptied box) reaches the part. */ @@ -366,6 +367,7 @@ export function FrameInspector({ onSize: (preset: FramePreset) => void; }) { const lang = useLang(); + const host = useHost(); const [copied, setCopied] = useState(false); const [saving, setSaving] = useState(false); const [swipeDir, setSwipeDir] = useState("left"); @@ -467,6 +469,9 @@ export function FrameInspector({ copied ? t("copied", lang) : t("prompt", lang), async () => { try { +let handled = false; + try { handled = host.onExport?.(prompt, null) === true; } catch {} + if (handled) { setCopied(true); return; } await navigator.clipboard.writeText(prompt); setCopied(true); } catch {} diff --git a/web/vendor/components/PromptPanel.tsx b/web/vendor/components/PromptPanel.tsx index af64f2a..ce84ba9 100644 --- a/web/vendor/components/PromptPanel.tsx +++ b/web/vendor/components/PromptPanel.tsx @@ -6,6 +6,7 @@ import { Doc, Palette, Platform, defaultPlatformOf } from "../lib/tokens"; import { Icon } from "./M3Node"; import { Field, IconBtn, Segmented } from "./ui"; import { t, useLang } from "../lib/i18n"; +import { useHost } from "../lib/host"; export function PromptPanel({ doc, @@ -22,6 +23,7 @@ export function PromptPanel({ const generated = useMemo(() => buildPrompt(doc, widths, undefined, lang), [doc, widths, lang]); const edited = doc.promptEdit !== undefined; const text = edited ? doc.promptEdit! : generated; + const host = useHost(); const [copied, setCopied] = useState(false); useEffect(() => { @@ -32,6 +34,9 @@ export function PromptPanel({ const copy = async () => { try { +let handled = false; + try { handled = host.onExport?.(text, null) === true; } catch {} + if (handled) { setCopied(true); return; } await navigator.clipboard.writeText(text); setCopied(true); } catch {} diff --git a/web/vendor/lib/host.ts b/web/vendor/lib/host.ts new file mode 100644 index 0000000..553db27 --- /dev/null +++ b/web/vendor/lib/host.ts @@ -0,0 +1,46 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { Doc } from "./tokens"; + +/** + * Hooks for an application that embeds the editor. + * + * The editor keeps its own state and its own UI; a host only needs to say where the document is kept + * and where the prompt goes. Every field is optional and every default is what the standalone app + * already did — localStorage for the document, the clipboard for the prompt — so an editor rendered + * without a provider behaves exactly as before. + * + * ```tsx + * sendToAgent(text) }}> + * + * + * ``` + */ +export type HostAdapter = { + /** Return the saved document, or null for a fresh one. Omit to read localStorage. */ + loadDoc?: () => Partial | null; + /** Persist the document. Omit to write localStorage. Called on every change, so debounce if remote. */ + saveDoc?: (doc: Partial) => void; + + /** Editor chrome — panel widths, favourites. Separate from the document because it is not the work. */ + loadUi?: () => unknown | null; + saveUi?: (ui: unknown) => void; + + /** + * Where the generated prompt goes. + * + * Return `true` to say the host has handled it and the editor should not also write the clipboard; + * anything else and the clipboard write still happens. That default matters — a host that only + * wants to observe the export does not have to reimplement copying. + */ + onExport?: (prompt: string, doc: Doc) => boolean | void; +}; + +const EMPTY: HostAdapter = {}; + +export const HostContext = createContext(EMPTY); + +export const HostProvider = HostContext.Provider; + +export const useHost = (): HostAdapter => useContext(HostContext);