初始化奇妙小屏幕控制器项目

This commit is contained in:
2026-09-08 22:56:52 +08:00
commit 8d368de3b5
491 changed files with 67678 additions and 0 deletions
@@ -0,0 +1,605 @@
const bootstrap = globalThis.__UI_COPY_EDITOR_BUILD__;
if (!bootstrap) {
throw new Error("UI copy editor bootstrap is missing");
}
const runtime = await import(bootstrap.runtime_url);
const placeholderPattern = /\{([a-zA-Z][a-zA-Z0-9_]*)\}/g;
const functionalValuePattern = /^[+\-]?(?:\d+(?:\.\d+)?|#[0-9a-fA-F]{3,8})(?:\s*(?:%|°|Hz|ms|px|V))?$/;
const [catalogResponse, draftResponse] = await Promise.all([
fetch(bootstrap.catalog_url, { cache: "no-store" }),
fetch(bootstrap.api_url, { cache: "no-store" }),
]);
if (!catalogResponse.ok || !draftResponse.ok) {
throw new Error("无法载入文案目录或草稿");
}
const catalog = await catalogResponse.json();
let draft = await draftResponse.json();
let etag = draftResponse.headers.get("ETag") || `"${draft.revision}"`;
let mode = "browse";
let selectedCopyId = null;
let selectedAnchorId = null;
let selectedInsertionId = null;
let saveBusy = false;
const history = [];
const staticTextNodes = new Map();
const toggle = document.createElement("button");
toggle.id = "ui-copy-editor-toggle";
toggle.type = "button";
toggle.textContent = "文案编辑";
toggle.setAttribute("aria-controls", "ui-copy-editor-panel");
toggle.setAttribute("aria-expanded", "false");
const panel = document.createElement("aside");
panel.id = "ui-copy-editor-panel";
panel.hidden = true;
panel.setAttribute("aria-label", "文案编辑器");
panel.innerHTML = `
<div class="ui-copy-editor-header">
<div><strong>文案编辑器</strong><span>输入不会立即改变页面</span></div>
<button id="ui-copy-editor-close" type="button" aria-label="关闭文案编辑器">×</button>
</div>
<div class="ui-copy-editor-modes" role="group" aria-label="编辑模式">
<button type="button" data-mode="browse">浏览页面</button>
<button type="button" data-mode="select">选择文字</button>
<button type="button" data-mode="insert">添加提示</button>
</div>
<div id="ui-copy-save-status" class="ui-copy-save-status" role="status" aria-live="polite">草稿已载入,等待操作</div>
<div class="ui-copy-editor-tools">
<button id="ui-copy-undo" type="button" disabled>撤销上次确认</button>
<a href="${bootstrap.export_url}" download>导出草稿</a>
</div>
<label class="ui-copy-search">搜索全部程序文案
<input id="ui-copy-search-input" type="search" autocomplete="off" placeholder="输入文字或 copy_id">
</label>
<div class="ui-copy-list-heading"><span>已登记文案与新增提示</span><span id="ui-copy-list-count"></span></div>
<div id="ui-copy-list" class="ui-copy-list"></div>
<form id="ui-copy-selected-form" class="ui-copy-selected-form" hidden novalidate>
<h3 id="ui-copy-selected-title"></h3>
<p id="ui-copy-selected-source"></p>
<label>文字内容<textarea id="ui-copy-text" rows="4" maxlength="2000"></textarea></label>
<p id="ui-copy-placeholder-note" class="ui-copy-placeholder-note" hidden></p>
<label id="ui-copy-font-label">字号(8–64 CSS px)
<input id="ui-copy-font-size" type="number" min="8" max="64" step="1" placeholder="保持默认字号">
</label>
<p id="ui-copy-form-error" class="ui-copy-form-error" role="alert" hidden></p>
<div class="ui-copy-form-actions">
<button id="ui-copy-confirm" class="ui-copy-confirm-button" type="submit">确定修改</button>
<button id="ui-copy-restore" type="button">恢复默认</button>
</div>
<button id="ui-copy-delete" class="ui-copy-danger-button" type="button">删除这行文字</button>
<div id="ui-copy-delete-confirmation" class="ui-copy-delete-confirmation" hidden>
<strong>确定隐藏这行文字?</strong>
<p>只隐藏文字,不会删除按钮、输入框或功能区域。</p>
<div class="ui-copy-form-actions">
<button id="ui-copy-delete-confirm" class="ui-copy-danger-button" type="button">确认删除文字</button>
<button id="ui-copy-delete-cancel" type="button">取消</button>
</div>
</div>
</form>
<form id="ui-copy-insertion-form" class="ui-copy-selected-form" hidden novalidate>
<h3 id="ui-copy-insertion-title">新增提示文字</h3>
<p id="ui-copy-insertion-source"></p>
<label>文字内容<textarea id="ui-copy-insertion-text" rows="4" maxlength="2000"></textarea></label>
<label>字号(8–64 CSS px)<input id="ui-copy-insertion-size" type="number" min="8" max="64" value="16" required></label>
<label>插入位置<select id="ui-copy-insertion-placement"><option value="before">锚点上方</option><option value="after">锚点下方</option></select></label>
<p id="ui-copy-insertion-error" class="ui-copy-form-error" role="alert" hidden></p>
<button class="ui-copy-confirm-button" type="submit">确定修改</button>
<button id="ui-copy-insertion-delete" class="ui-copy-danger-button" type="button" hidden>删除这条新增提示</button>
<div id="ui-copy-insertion-delete-confirmation" class="ui-copy-delete-confirmation" hidden>
<strong>确定删除这条新增提示?</strong>
<div class="ui-copy-form-actions">
<button id="ui-copy-insertion-delete-confirm" class="ui-copy-danger-button" type="button">确认删除提示</button>
<button id="ui-copy-insertion-delete-cancel" type="button">取消</button>
</div>
</div>
</form>
`;
document.body.append(toggle, panel);
const elements = {
status: panel.querySelector("#ui-copy-save-status"),
list: panel.querySelector("#ui-copy-list"),
count: panel.querySelector("#ui-copy-list-count"),
search: panel.querySelector("#ui-copy-search-input"),
undo: panel.querySelector("#ui-copy-undo"),
copyForm: panel.querySelector("#ui-copy-selected-form"),
copyTitle: panel.querySelector("#ui-copy-selected-title"),
copySource: panel.querySelector("#ui-copy-selected-source"),
text: panel.querySelector("#ui-copy-text"),
font: panel.querySelector("#ui-copy-font-size"),
fontLabel: panel.querySelector("#ui-copy-font-label"),
placeholderNote: panel.querySelector("#ui-copy-placeholder-note"),
formError: panel.querySelector("#ui-copy-form-error"),
restore: panel.querySelector("#ui-copy-restore"),
delete: panel.querySelector("#ui-copy-delete"),
deleteConfirmation: panel.querySelector("#ui-copy-delete-confirmation"),
insertionForm: panel.querySelector("#ui-copy-insertion-form"),
insertionTitle: panel.querySelector("#ui-copy-insertion-title"),
insertionSource: panel.querySelector("#ui-copy-insertion-source"),
insertionText: panel.querySelector("#ui-copy-insertion-text"),
insertionSize: panel.querySelector("#ui-copy-insertion-size"),
insertionPlacement: panel.querySelector("#ui-copy-insertion-placement"),
insertionError: panel.querySelector("#ui-copy-insertion-error"),
insertionDelete: panel.querySelector("#ui-copy-insertion-delete"),
insertionDeleteConfirmation: panel.querySelector("#ui-copy-insertion-delete-confirmation"),
};
function normalizedText(value) {
return String(value).replace(/\s+/g, " ").trim();
}
function editableTextNodes(element) {
return Array.from(element.childNodes).filter((node) => {
if (node.nodeType !== Node.TEXT_NODE) return false;
const text = normalizedText(node.nodeValue || "");
return text && text !== "-" && !functionalValuePattern.test(text);
});
}
function wrapTextNode(node, text) {
const span = document.createElement("span");
span.className = "ui-copy-editable-text";
span.textContent = text;
const raw = node.nodeValue || "";
const leading = raw.match(/^\s*/)?.[0] || "";
const trailing = raw.match(/\s*$/)?.[0] || "";
node.replaceWith(document.createTextNode(leading), span, document.createTextNode(trailing));
return span;
}
function bindStaticCatalog() {
for (const [copyId, item] of Object.entries(catalog.items)) {
const binding = item.render;
if (!binding || binding.type === "dynamic_template") continue;
let host;
try {
host = document.querySelector(binding.selector);
} catch (_error) {
continue;
}
if (!host || panel.contains(host)) continue;
if (binding.type === "attribute") {
runtime.registerStaticUiCopy(copyId, host, host, item.text, item.kind, binding.attribute);
continue;
}
if (!staticTextNodes.has(binding.selector)) {
staticTextNodes.set(binding.selector, editableTextNodes(host));
}
const node = staticTextNodes.get(binding.selector)[binding.text_index];
let target;
if (node?.isConnected) target = host.tagName === "OPTION" ? host : wrapTextNode(node, item.text);
else if (binding.default_hidden) {
target = document.createElement("span");
target.className = "ui-copy-editable-text";
host.append(target);
} else continue;
runtime.registerStaticUiCopy(copyId, target, host, item.text, item.kind, null, binding.default_hidden);
}
}
function bindAnchors() {
for (const [anchorId, anchor] of Object.entries(catalog.anchors)) {
let element;
try {
element = document.querySelector(anchor.selector);
} catch (_error) {
continue;
}
if (element && !panel.contains(element)) element.dataset.uiCopyAnchorId = anchorId;
}
}
function setStatus(text, state = "idle") {
elements.status.textContent = text;
elements.status.dataset.state = state;
}
function showError(element, text = "") {
element.textContent = text;
element.hidden = !text;
}
function clone(value) {
return JSON.parse(JSON.stringify(value));
}
function writableDocument(value) {
return {
schema_version: value.schema_version,
base_catalog_digest: value.base_catalog_digest,
revision: value.revision,
updated_at: value.updated_at,
overrides: clone(value.overrides),
insertions: clone(value.insertions),
};
}
function placeholders(value) {
return Array.from(String(value).matchAll(placeholderPattern), (match) => match[1]).sort();
}
function samePlaceholders(left, right) {
return JSON.stringify(placeholders(left)) === JSON.stringify(placeholders(right));
}
function effectiveText(copyId) {
return draft.overrides[copyId]?.text ?? catalog.items[copyId].text;
}
function insertionElementId(id) {
return `ui-copy-insertion-${id}`;
}
function applyInsertions() {
document.querySelectorAll(".ui-copy-custom-insertion").forEach((element) => element.remove());
for (const insertion of draft.insertions) {
const anchor = document.querySelector(`[data-ui-copy-anchor-id="${CSS.escape(insertion.anchor_id)}"]`);
if (!anchor) continue;
const element = document.createElement("p");
element.id = insertionElementId(insertion.id);
element.className = "ui-copy-custom-insertion";
element.dataset.uiCopyInsertionId = insertion.id;
element.textContent = insertion.text;
element.style.fontSize = `${insertion.font_size_px}px`;
anchor.insertAdjacentElement(insertion.placement === "before" ? "beforebegin" : "afterend", element);
}
}
function updateSelectionOutline() {
document.querySelectorAll(".ui-copy-selected").forEach((element) => element.classList.remove("ui-copy-selected"));
if (selectedCopyId) {
runtime.uiCopyRecords(selectedCopyId).forEach((record) => record.target.classList.add("ui-copy-selected"));
} else if (selectedInsertionId) {
document.getElementById(insertionElementId(selectedInsertionId))?.classList.add("ui-copy-selected");
}
}
function renderList() {
const query = normalizedText(elements.search.value).toLocaleLowerCase();
const entries = Object.entries(catalog.items)
.filter(([copyId, item]) => `${copyId} ${item.text} ${effectiveText(copyId)} ${item.scope}`.toLocaleLowerCase().includes(query))
.map(([copyId, item]) => ({ type: "copy", id: copyId, text: effectiveText(copyId), meta: `${item.scope} · ${item.render.type}` }));
for (const insertion of draft.insertions) {
if (`${insertion.id} ${insertion.text}`.toLocaleLowerCase().includes(query)) {
entries.push({ type: "insertion", id: insertion.id, text: insertion.text, meta: "新增提示" });
}
}
elements.list.replaceChildren();
for (const entry of entries) {
const button = document.createElement("button");
button.type = "button";
button.className = "ui-copy-list-item";
button.classList.toggle("active", entry.id === selectedCopyId || entry.id === selectedInsertionId);
const text = document.createElement("span");
text.className = "ui-copy-list-text";
text.textContent = entry.text || "(已隐藏)";
const meta = document.createElement("span");
meta.className = "ui-copy-list-meta";
meta.textContent = entry.type === "copy" && !runtime.uiCopyRecords(entry.id).length
? `${entry.meta} · 当前未显示`
: entry.meta;
button.append(text, meta);
button.addEventListener("click", () => entry.type === "copy" ? selectCopy(entry.id) : selectInsertion(entry.id));
elements.list.append(button);
}
elements.count.textContent = String(entries.length);
}
function resetCopyFields(copyId) {
const item = catalog.items[copyId];
const override = draft.overrides[copyId] || {};
elements.text.value = override.text ?? item.text;
elements.font.value = override.font_size_px ?? "";
const effectiveHidden = Object.hasOwn(override, "hidden") ? override.hidden : Boolean(item.render.default_hidden);
elements.delete.textContent = effectiveHidden ? "恢复显示这行文字" : "删除这行文字";
elements.delete.disabled = item.kind === "html_attribute";
elements.restore.disabled = !draft.overrides[copyId];
elements.fontLabel.hidden = item.kind === "html_attribute";
elements.delete.hidden = item.kind === "html_attribute";
const tokens = placeholders(item.text);
elements.placeholderNote.hidden = tokens.length === 0;
elements.placeholderNote.textContent = tokens.length ? `必须原样保留变量:${tokens.map((token) => `{${token}}`).join("、")}` : "";
elements.deleteConfirmation.hidden = true;
showError(elements.formError);
}
function selectCopy(copyId) {
if (!catalog.items[copyId]) return;
selectedCopyId = copyId;
selectedInsertionId = null;
selectedAnchorId = null;
const item = catalog.items[copyId];
elements.copyTitle.textContent = copyId;
elements.copySource.textContent = `${item.scope} · ${item.source}`;
resetCopyFields(copyId);
elements.copyForm.hidden = false;
elements.insertionForm.hidden = true;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
elements.text.focus();
}
function selectInsertion(insertionId) {
const insertion = draft.insertions.find((item) => item.id === insertionId);
if (!insertion) return;
selectedCopyId = null;
selectedAnchorId = insertion.anchor_id;
selectedInsertionId = insertionId;
elements.insertionTitle.textContent = "修改新增提示";
elements.insertionSource.textContent = `${insertion.workspace_id} · ${insertion.anchor_id}`;
elements.insertionText.value = insertion.text;
elements.insertionSize.value = insertion.font_size_px;
elements.insertionPlacement.value = insertion.placement;
elements.insertionDelete.hidden = false;
elements.insertionDeleteConfirmation.hidden = true;
showError(elements.insertionError);
elements.copyForm.hidden = true;
elements.insertionForm.hidden = false;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
}
function selectAnchor(anchorId) {
const anchor = catalog.anchors[anchorId];
if (!anchor) return;
selectedCopyId = null;
selectedInsertionId = null;
selectedAnchorId = anchorId;
elements.insertionTitle.textContent = "新增提示文字";
elements.insertionSource.textContent = `${anchor.scope} · ${anchor.selector}`;
elements.insertionText.value = "";
elements.insertionSize.value = "16";
elements.insertionPlacement.value = "after";
elements.insertionDelete.hidden = true;
elements.insertionDeleteConfirmation.hidden = true;
showError(elements.insertionError);
elements.copyForm.hidden = true;
elements.insertionForm.hidden = false;
document.documentElement.dataset.uiCopyEditorSelection = "true";
updateSelectionOutline();
renderList();
elements.insertionText.focus();
}
function setMode(nextMode) {
mode = nextMode;
document.documentElement.dataset.uiCopyEditorMode = mode;
document.documentElement.dataset.uiCopyEditorSelection = String(Boolean(selectedCopyId || selectedInsertionId || selectedAnchorId));
panel.querySelectorAll("[data-mode]").forEach((button) => button.classList.toggle("active", button.dataset.mode === mode));
document.querySelectorAll("[data-ui-copy-anchor-id]").forEach((element) => {
element.classList.toggle("ui-copy-anchor-candidate", mode === "insert");
});
if (mode === "select") setStatus("点击页面中带轮廓的程序文案;原控件不会被触发");
else if (mode === "insert") setStatus("点击页面中的虚线区域,选择新增提示位置");
else setStatus("浏览模式:页面功能保持可用");
}
async function commitCandidate(candidate, { recordHistory = true, afterSave = null } = {}) {
if (saveBusy) return false;
saveBusy = true;
setStatus("正在保存,页面尚未改变…", "saving");
const previous = writableDocument(draft);
try {
const response = await fetch(bootstrap.api_url, {
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": etag },
body: JSON.stringify(writableDocument(candidate)),
});
if (!response.ok) {
let message = `保存失败(${response.status})`;
try {
const detail = await response.json();
if (detail?.detail) message = detail.detail;
} catch (_error) {}
if (response.status === 409) setStatus(`草稿冲突:${message}。页面和输入内容均未改变,请刷新后重试。`, "conflict");
else setStatus(`${message}。页面和输入内容均未改变,可直接重试。`, "error");
return false;
}
const saved = await response.json();
etag = response.headers.get("ETag") || `"${saved.revision}"`;
if (recordHistory) history.push(previous);
draft = saved;
runtime.setUiCopyDraft(draft);
applyInsertions();
afterSave?.();
elements.undo.disabled = history.length === 0;
updateSelectionOutline();
renderList();
setStatus(`已保存修订 ${draft.revision},页面已更新`, "saved");
return true;
} catch (error) {
setStatus(`保存失败:${error.message}。页面和输入内容均未改变,可直接重试。`, "error");
return false;
} finally {
saveBusy = false;
}
}
function copyCandidate(copyId, override) {
const candidate = writableDocument(draft);
if (Object.keys(override).length) candidate.overrides[copyId] = override;
else delete candidate.overrides[copyId];
return candidate;
}
elements.copyForm.addEventListener("input", () => {
showError(elements.formError);
setStatus("修改暂存在编辑框中;点击“确定修改”前页面不会改变", "pending");
});
elements.copyForm.addEventListener("submit", async (event) => {
event.preventDefault();
const copyId = selectedCopyId;
if (!copyId) return;
const item = catalog.items[copyId];
const prior = draft.overrides[copyId] || {};
const text = elements.text.value;
if (!text.trim()) {
resetCopyFields(copyId);
showError(elements.formError, "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。");
setStatus("未保存:空文字已恢复,页面没有改变", "error");
return;
}
if (!samePlaceholders(text, item.text)) {
showError(elements.formError, "变量占位符不能删除、增加或改名。");
return;
}
const override = {};
if (text !== item.text) override.text = text;
if (item.kind !== "html_attribute" && elements.font.value !== "") {
const size = Number(elements.font.value);
if (!Number.isInteger(size) || size < 8 || size > 64) {
showError(elements.formError, "字号必须是 8 到 64 之间的整数。");
return;
}
override.font_size_px = size;
}
if (Object.hasOwn(prior, "hidden")) override.hidden = prior.hidden;
await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) });
});
elements.restore.addEventListener("click", async () => {
if (!selectedCopyId) return;
const copyId = selectedCopyId;
await commitCandidate(copyCandidate(copyId, {}), { afterSave: () => resetCopyFields(copyId) });
});
elements.delete.addEventListener("click", () => {
if (!selectedCopyId) return;
const item = catalog.items[selectedCopyId];
const current = draft.overrides[selectedCopyId] || {};
const effectiveHidden = Object.hasOwn(current, "hidden") ? current.hidden : Boolean(item.render.default_hidden);
if (effectiveHidden) {
const override = { ...current, hidden: false };
void commitCandidate(copyCandidate(selectedCopyId, override), { afterSave: () => resetCopyFields(selectedCopyId) });
return;
}
elements.deleteConfirmation.hidden = false;
elements.deleteConfirmation.querySelector("#ui-copy-delete-confirm").focus();
});
panel.querySelector("#ui-copy-delete-cancel").addEventListener("click", () => { elements.deleteConfirmation.hidden = true; });
panel.querySelector("#ui-copy-delete-confirm").addEventListener("click", async () => {
if (!selectedCopyId) return;
const copyId = selectedCopyId;
const override = { ...(draft.overrides[copyId] || {}), hidden: true };
await commitCandidate(copyCandidate(copyId, override), { afterSave: () => resetCopyFields(copyId) });
});
elements.insertionForm.addEventListener("input", () => {
showError(elements.insertionError);
setStatus("新增提示的修改尚未确认;页面不会改变", "pending");
});
elements.insertionForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!selectedAnchorId) return;
const text = elements.insertionText.value;
if (!text.trim()) {
const existing = draft.insertions.find((item) => item.id === selectedInsertionId);
if (existing) elements.insertionText.value = existing.text;
showError(elements.insertionError, existing
? "文字内容不能留空。已恢复到本次编辑前的文字,没有发送保存请求。"
: "文字内容不能留空,没有发送保存请求。");
return;
}
const size = Number(elements.insertionSize.value);
if (!Number.isInteger(size) || size < 8 || size > 64) {
showError(elements.insertionError, "字号必须是 8 到 64 之间的整数。");
return;
}
const anchor = catalog.anchors[selectedAnchorId];
const insertion = {
id: selectedInsertionId || crypto.randomUUID(),
workspace_id: anchor.scope,
anchor_id: selectedAnchorId,
placement: elements.insertionPlacement.value,
text,
font_size_px: size,
};
const candidate = writableDocument(draft);
const index = candidate.insertions.findIndex((item) => item.id === insertion.id);
if (index >= 0) candidate.insertions[index] = insertion;
else candidate.insertions.push(insertion);
await commitCandidate(candidate, { afterSave: () => selectInsertion(insertion.id) });
});
elements.insertionDelete.addEventListener("click", () => {
elements.insertionDeleteConfirmation.hidden = false;
panel.querySelector("#ui-copy-insertion-delete-confirm").focus();
});
panel.querySelector("#ui-copy-insertion-delete-cancel").addEventListener("click", () => { elements.insertionDeleteConfirmation.hidden = true; });
panel.querySelector("#ui-copy-insertion-delete-confirm").addEventListener("click", async () => {
if (!selectedInsertionId) return;
const candidate = writableDocument(draft);
candidate.insertions = candidate.insertions.filter((item) => item.id !== selectedInsertionId);
await commitCandidate(candidate, {
afterSave: () => {
selectedInsertionId = null;
selectedAnchorId = null;
elements.insertionForm.hidden = true;
document.documentElement.dataset.uiCopyEditorSelection = "false";
},
});
});
elements.undo.addEventListener("click", async () => {
if (!history.length) return;
const target = clone(history[history.length - 1]);
target.revision = draft.revision;
target.updated_at = draft.updated_at;
target.base_catalog_digest = draft.base_catalog_digest;
const saved = await commitCandidate(target, {
recordHistory: false,
afterSave: () => {
history.pop();
if (selectedCopyId) resetCopyFields(selectedCopyId);
if (selectedInsertionId && !draft.insertions.some((item) => item.id === selectedInsertionId)) {
selectedInsertionId = null;
elements.insertionForm.hidden = true;
}
},
});
if (saved) elements.undo.disabled = history.length === 0;
});
panel.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => setMode(button.dataset.mode)));
elements.search.addEventListener("input", renderList);
document.addEventListener("click", (event) => {
if (panel.contains(event.target) || toggle.contains(event.target)) return;
if (mode === "select") {
const target = event.target.closest?.("[data-ui-copy-id]");
if (!target) return;
event.preventDefault();
event.stopImmediatePropagation();
selectCopy(target.dataset.uiCopyId);
} else if (mode === "insert") {
const anchor = event.target.closest?.("[data-ui-copy-anchor-id]");
if (!anchor) return;
event.preventDefault();
event.stopImmediatePropagation();
selectAnchor(anchor.dataset.uiCopyAnchorId);
}
}, true);
function setPanelOpen(open) {
panel.hidden = !open;
toggle.setAttribute("aria-expanded", String(open));
if (open) panel.querySelector("[data-mode].active")?.focus();
}
toggle.addEventListener("click", () => setPanelOpen(panel.hidden));
panel.querySelector("#ui-copy-editor-close").addEventListener("click", () => setPanelOpen(false));
bindStaticCatalog();
bindAnchors();
runtime.setUiCopyDraft(draft);
applyInsertions();
renderList();
setMode("browse");