the vendoring machinery: one command reproduces the whole state

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 12:04:34 +01:00
co-authored by Claude Fable 5
parent 240d63940d
commit e99b4afbf9
7 changed files with 373 additions and 23 deletions
+46
View File
@@ -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
* <HostProvider value={{ loadDoc, saveDoc, onExport: (text) => sendToAgent(text) }}>
* <Canvas />
* </HostProvider>
* ```
*/
export type HostAdapter = {
/** Return the saved document, or null for a fresh one. Omit to read localStorage. */
loadDoc?: () => Partial<Doc> | null;
/** Persist the document. Omit to write localStorage. Called on every change, so debounce if remote. */
saveDoc?: (doc: Partial<Doc>) => 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<HostAdapter>(EMPTY);
export const HostProvider = HostContext.Provider;
export const useHost = (): HostAdapter => useContext(HostContext);
+27 -23
View File
@@ -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<Group[]>(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<Doc>, 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<Doc>);
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 {}
}}
+5
View File
@@ -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<SwipeDir>("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 {}
+5
View File
@@ -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 {}
+46
View File
@@ -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
* <HostProvider value={{ loadDoc, saveDoc, onExport: (text) => sendToAgent(text) }}>
* <Canvas />
* </HostProvider>
* ```
*/
export type HostAdapter = {
/** Return the saved document, or null for a fresh one. Omit to read localStorage. */
loadDoc?: () => Partial<Doc> | null;
/** Persist the document. Omit to write localStorage. Called on every change, so debounce if remote. */
saveDoc?: (doc: Partial<Doc>) => 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<HostAdapter>(EMPTY);
export const HostProvider = HostContext.Provider;
export const useHost = (): HostAdapter => useContext(HostContext);