diff --git a/.playwright-mcp/console-2026-06-15T08-02-31-469Z.log b/.playwright-mcp/console-2026-06-15T08-02-31-469Z.log
new file mode 100644
index 0000000..8809e27
--- /dev/null
+++ b/.playwright-mcp/console-2026-06-15T08-02-31-469Z.log
@@ -0,0 +1,7 @@
+[ 282ms] [INFO] You are running a development build of Vue.
+Make sure to use the production build (*.prod.js) when deploying for production. @ https://unpkg.com/vue@3/dist/vue.global.js:12834
+[ 292ms] Identifier 'ref' has already been declared
+[ 293ms] Identifier 'ref' has already been declared
+[ 293ms] Identifier 'ref' has already been declared
+[ 293ms] Identifier 'ref' has already been declared
+[ 298ms] [ERROR] Failed to load resource: the server responded with a status of 404 (File not found) @ http://localhost:8753/favicon.ico:0
diff --git a/.playwright-mcp/page-2026-06-15T08-02-31-796Z.yml b/.playwright-mcp/page-2026-06-15T08-02-31-796Z.yml
new file mode 100644
index 0000000..e69de29
diff --git a/design_handoff_helder_workbench/design-vue/index.html b/design_handoff_helder_workbench/design-vue/index.html
new file mode 100644
index 0000000..e288e1a
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Helder — AI Code Workbench
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design_handoff_helder_workbench/design-vue/src/app.js b/design_handoff_helder_workbench/design-vue/src/app.js
new file mode 100644
index 0000000..a38bdd0
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/app.js
@@ -0,0 +1,464 @@
+/* App shell: 4 resizable columns, keyboard shortcuts, copy-reference, status bar */
+const { ref, computed, reactive, onMounted, onUnmounted, watch, nextTick } = Vue;
+
+/* ---- Helpers ---- */
+function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
+function ancestors(path) {
+ const parts = path.split("/"); const out = [];
+ for (let i = 1; i < parts.length; i++) out.push(parts.slice(0, i).join("/"));
+ return out;
+}
+function initialOpenDirs(node, set) {
+ if (node.type === "dir") {
+ if (node.open && node.path) set.add(node.path);
+ (node.children || []).forEach((c) => initialOpenDirs(c, set));
+ }
+ return set;
+}
+
+/* ---- Splitter ---- */
+const Splitter = {
+ props: {
+ orientation: { type: String, default: "v" },
+ onDelta: { type: Function, required: true },
+ },
+ setup(props) {
+ const drag = ref(false);
+
+ function down(e) {
+ e.preventDefault();
+ let last = { x: e.clientX, y: e.clientY };
+ drag.value = true;
+ document.body.style.cursor = props.orientation === "v" ? "col-resize" : "row-resize";
+ document.body.style.userSelect = "none";
+
+ function mv(ev) {
+ props.onDelta(ev.clientX - last.x, ev.clientY - last.y);
+ last = { x: ev.clientX, y: ev.clientY };
+ }
+ function up() {
+ drag.value = false;
+ document.body.style.cursor = "";
+ document.body.style.userSelect = "";
+ document.removeEventListener("mousemove", mv);
+ document.removeEventListener("mouseup", up);
+ }
+ document.addEventListener("mousemove", mv);
+ document.addEventListener("mouseup", up);
+ }
+
+ return { drag, down };
+ },
+ template: `
`,
+};
+
+/* ---- RightColumn ---- */
+const RightColumn = {
+ props: {
+ width: { type: Number, required: true },
+ },
+ setup(props) {
+ const topFrac = ref(0.52);
+ const colRef = ref(null);
+
+ function delta(dx, dy) {
+ const h = colRef.value ? colRef.value.clientHeight : 600;
+ topFrac.value = Math.max(0.18, Math.min(0.82, (topFrac.value * h + dy) / h));
+ }
+
+ const agent = agentSeed();
+ const shell = shellSeed();
+
+ return { topFrac, colRef, delta, agent, shell };
+ },
+ template: `
+
+ `,
+};
+
+/* ---- App ---- */
+const App = {
+ setup() {
+ /* --- derived constants (no reactivity needed) --- */
+ const changeMap = computed(() => Object.fromEntries(PROJECT.changes.map((c) => [c.path, c.status])));
+ const changeSet = computed(() => new Set(PROJECT.changes.map((c) => c.path)));
+
+ /* --- state --- */
+ const tabs = ref([
+ { path: "src/Http/Controller/UserController.php" },
+ { path: "public/assets/app.js" },
+ { path: "src/types/api.ts" },
+ ]);
+ const active = ref("src/Http/Controller/UserController.php");
+ const tabMode = ref({ "src/Http/Controller/UserController.php": "diff" });
+ const openDirs = ref(initialOpenDirs(PROJECT.tree, new Set()));
+ const cursor = ref({ path: "src/Http/Controller/UserController.php", line: 1, col: 1 });
+ const selection = ref(null);
+ const overlay = ref(null);
+ const menu = ref(null);
+ const toasts = ref([]);
+ const splitFor = ref(null);
+ const staged = ref(new Set(["src/Service/PaymentService.php", "config/app.json"]));
+ const committed = ref(new Set());
+ const commitMsg = ref("");
+ const passPopup = ref(null);
+
+ const gitW = ref(232);
+ const treeW = ref(244);
+ const rightW = ref(444);
+
+ /* --- computed --- */
+ const MODE_LABEL = { original: "orig", updated: "upd", diff: "diff", code: "" };
+ const MODE_WORD = { original: "Original", updated: "Updated", diff: "Diff" };
+
+ const resolvedTabs = computed(() =>
+ tabs.value.map((t) => {
+ const changed = !!PROJECT.diffs[t.path];
+ const m = tabMode.value[t.path] || (changed ? "diff" : "code");
+ return { ...t, changed, modeLabel: splitFor.value === t.path ? "split" : MODE_LABEL[m] };
+ })
+ );
+
+ const mode = computed(() => tabMode.value[active.value] || (PROJECT.diffs[active.value] ? "diff" : "code"));
+ const totals = computed(() => PROJECT.changes.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 }));
+ const activeLang = computed(() => active.value ? HL.langLabel(active.value) : "");
+ const crumb = computed(() => active.value ? active.value.split("/") : []);
+
+ /* --- helpers --- */
+ function toast(title, refText) {
+ const id = lid();
+ toasts.value = [...toasts.value, { id, title, ref: refText }];
+ setTimeout(() => { toasts.value = toasts.value.filter((x) => x.id !== id); }, 2300);
+ }
+
+ async function copyText(text, label) {
+ try { await navigator.clipboard.writeText(text); }
+ catch (e) {
+ const ta = document.createElement("textarea"); ta.value = text;
+ ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta);
+ ta.select(); try { document.execCommand("copy"); } catch (_) {} ta.remove();
+ }
+ toast(label || "Copied reference", text);
+ }
+
+ function toggleDir(p) {
+ const n = new Set(openDirs.value);
+ n.has(p) ? n.delete(p) : n.add(p);
+ openDirs.value = n;
+ }
+
+ function reveal(path) {
+ const n = new Set(openDirs.value);
+ ancestors(path).forEach((a) => n.add(a));
+ openDirs.value = n;
+ }
+
+ function stage(p) {
+ const n = new Set(staged.value);
+ n.add(p);
+ staged.value = n;
+ }
+ function unstage(p) {
+ const n = new Set(staged.value);
+ n.delete(p);
+ staged.value = n;
+ }
+ function stageAll() {
+ staged.value = new Set(PROJECT.changes.filter((c) => !committed.value.has(c.path)).map((c) => c.path));
+ }
+ function unstageAll() {
+ staged.value = new Set();
+ }
+ function commit() {
+ const list = PROJECT.changes.filter((c) => staged.value.has(c.path) && !committed.value.has(c.path));
+ if (!list.length || !commitMsg.value.trim()) return;
+ const n = new Set(committed.value);
+ list.forEach((c) => n.add(c.path));
+ committed.value = n;
+ staged.value = new Set();
+ const msg = commitMsg.value.trim();
+ commitMsg.value = "";
+ toast(`Committed ${list.length} file${list.length > 1 ? "s" : ""}`, msg.length > 34 ? msg.slice(0, 34) + "…" : msg);
+ }
+
+ function openFile(path, opts = {}) {
+ const changed = !!PROJECT.diffs[path];
+ if (!tabs.value.some((x) => x.path === path)) {
+ tabs.value = [...tabs.value, { path }];
+ }
+ active.value = path;
+ if (opts.diff && changed) {
+ tabMode.value = { ...tabMode.value, [path]: "diff" };
+ } else if (!tabMode.value[path]) {
+ tabMode.value = { ...tabMode.value, [path]: changed ? "diff" : "code" };
+ }
+ reveal(path);
+ if (opts.line) {
+ // show the current/updated file so line numbers map to search hits
+ splitFor.value = null;
+ tabMode.value = { ...tabMode.value, [path]: changed ? "updated" : "code" };
+ cursor.value = { path, line: opts.line, col: 1 };
+ selection.value = null;
+ setTimeout(() => {
+ const row = document.querySelector('.editor .ln-row[data-line="' + opts.line + '"]');
+ if (row) { const ed = row.closest(".editor"); const er = ed.getBoundingClientRect(), rr = row.getBoundingClientRect(); ed.scrollTop += (rr.top - er.top) - ed.clientHeight / 2; }
+ }, 70);
+ }
+ }
+
+ function closeTab(path) {
+ const t = tabs.value;
+ const ix = t.findIndex((x) => x.path === path);
+ const next = t.filter((x) => x.path !== path);
+ if (path === active.value) {
+ const fallback = next[ix] || next[ix - 1] || next[next.length - 1];
+ active.value = fallback ? fallback.path : null;
+ }
+ tabs.value = next;
+ }
+
+ /* ---- context menus ---- */
+ function openMenu(e, target) {
+ e.preventDefault(); e.stopPropagation();
+ const sparkSend = (ref) => ({
+ icon: "spark",
+ label: "Send reference to agent",
+ onClick: () => {
+ window.dispatchEvent(new CustomEvent("agentPaste", { detail: ref }));
+ toast("Passed to agent", ref);
+ },
+ });
+ if (target.kind === "editor") {
+ const ref = target.sel
+ ? `${target.path}:${target.sel.start}-${target.sel.end}`
+ : `${target.path}:${target.line}`;
+ const mx = e.clientX, my = e.clientY;
+ menu.value = {
+ x: mx, y: my, note: ref,
+ items: [
+ { primary: true, icon: "copy", label: "Copy reference", onClick: () => copyText(ref) },
+ { icon: "spark", label: "Pass on to Agent", onClick: () => { passPopup.value = { x: mx, y: my, ref }; } },
+ ],
+ };
+ } else {
+ const isDir = target.kind === "dir";
+ const ref = isDir ? target.path + "/" : target.path;
+ const name = target.path.split("/").pop();
+ const items = [
+ { primary: true, icon: "copy", label: "Copy reference", onClick: () => copyText(ref) },
+ sparkSend(ref),
+ { icon: "copy", label: isDir ? "Copy folder path" : "Copy file name", onClick: () => copyText(isDir ? target.path : name, "Copied") },
+ ];
+ if (!isDir) {
+ items.push({ sep: true });
+ if (target.kind === "git") {
+ const isStaged = staged.value.has(target.path);
+ items.push(isStaged
+ ? { icon: "minus", label: "Unstage changes", onClick: () => unstage(target.path) }
+ : { icon: "plus", label: "Stage changes", onClick: () => stage(target.path) });
+ items.push({ icon: "diff", label: "Open diff", onClick: () => openFile(target.path, { diff: true }) });
+ }
+ items.push({ icon: "file", label: "Open file", onClick: () => openFile(target.path) });
+ items.push({ icon: "reveal", label: "Reveal in Explorer", onClick: () => reveal(target.path) });
+ }
+ menu.value = { x: e.clientX, y: e.clientY, note: ref, items };
+ }
+ }
+
+ /* ---- keyboard shortcuts ---- */
+ function onKey(e) {
+ const meta = e.metaKey || e.ctrlKey;
+ if (meta && e.key.toLowerCase() === "f") { e.preventDefault(); overlay.value = "search"; }
+ else if (meta && e.key.toLowerCase() === "w") { e.preventDefault(); if (active.value) closeTab(active.value); }
+ else if (e.key === "Escape") {
+ if (splitFor.value) splitFor.value = null;
+ else { overlay.value = null; menu.value = null; }
+ }
+ }
+
+ onMounted(() => window.addEventListener("keydown", onKey));
+ onUnmounted(() => window.removeEventListener("keydown", onKey));
+
+ /* --- passPopup confirm handler (passed as prop) --- */
+ function onPassConfirm(text) {
+ const line = (text && text.trim() ? text.trim() + " " : "") + passPopup.value.ref;
+ window.dispatchEvent(new CustomEvent("agentPaste", { detail: line }));
+ const savedRef = passPopup.value.ref;
+ passPopup.value = null;
+ toast("Passed to agent", savedRef);
+ }
+ function onPassCancel() { passPopup.value = null; }
+
+ function setCommitMsg(v) { commitMsg.value = v; }
+ function setActive(p) { active.value = p; }
+ function setMode(m) { tabMode.value = { ...tabMode.value, [active.value]: m }; splitFor.value = null; }
+ function setCursorVal(v) { cursor.value = v; }
+ function setSelectionVal(v) { selection.value = v; }
+ function setSplitFor(p) { splitFor.value = p; }
+
+ function clampGitW(dx) { gitW.value = clamp(gitW.value + dx, 160, 460); }
+ function clampTreeW(dx) { treeW.value = clamp(treeW.value + dx, 160, 520); }
+ function clampRightW(dx) { rightW.value = clamp(rightW.value - dx, 280, 780); }
+
+ return {
+ changeMap, changeSet,
+ tabs, active, tabMode, openDirs, cursor, selection, overlay, menu, toasts, splitFor,
+ staged, committed, commitMsg, passPopup,
+ gitW, treeW, rightW,
+ resolvedTabs, mode, totals, activeLang, crumb,
+ MODE_LABEL, MODE_WORD,
+ toast, copyText, toggleDir, reveal,
+ stage, unstage, stageAll, unstageAll, commit,
+ openFile, closeTab, openMenu,
+ onPassConfirm, onPassCancel,
+ setCommitMsg, setActive, setMode, setCursorVal, setSelectionVal, setSplitFor,
+ clampGitW, clampTreeW, clampRightW,
+ PROJECT, /* expose global so template can reference it */
+ };
+ },
+
+ template: `
+
+
+
+
+
+
+
+ Helder
+ —
+ {{ PROJECT.name }}
+
+
+
+ ›
+ {{ s }}
+
+
+
+
+
+ Search ⌘F
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ PROJECT.branch }}
+
+
+ +{{ totals.add }}
+ −{{ totals.del }}
+
+
+
+
+ {{ selection.end - selection.start + 1 }} lines selected
+
+
+ Ln {{ cursor.path === active ? cursor.line : 1 }}, Col {{ cursor.path === active ? cursor.col : 1 }}
+
+
+
Spaces: 4
+
UTF-8
+
{{ activeLang }}
+
+ {{ splitFor === active ? 'Split' : (MODE_WORD[mode] || '') }}
+
+
+
+
+
+
+
+
+
+
+
+ `,
+};
+
+/* ---- Mount ---- */
+const app = Vue.createApp(App);
+Object.entries(window.HelderComponents).forEach(([n, c]) => app.component(n, c));
+app.component("Splitter", Splitter);
+app.component("RightColumn", RightColumn);
+app.mount("#app");
diff --git a/design_handoff_helder_workbench/design-vue/src/components.js b/design_handoff_helder_workbench/design-vue/src/components.js
new file mode 100644
index 0000000..ebbabd9
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/components.js
@@ -0,0 +1,395 @@
+/* Shared icons, FileIcon, GitPanel, FileTree — Vue 3 classic-script port */
+const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
+
+/* ---- Icon component ---- */
+/* Internal map: name → { vb, inner } */
+const _iconMap = {
+ search: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ branch: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ close: {
+ vb: "0 0 12 12",
+ inner: ` `
+ },
+ copy: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ terminal: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ spark: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ file: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ reveal: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ diff: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+ plus: {
+ vb: "0 0 14 14",
+ inner: ` `
+ },
+ minus: {
+ vb: "0 0 14 14",
+ inner: ` `
+ },
+ check: {
+ vb: "0 0 14 14",
+ inner: ` `
+ },
+ discard: {
+ vb: "0 0 16 16",
+ inner: ` `
+ },
+};
+
+const Icon = {
+ props: {
+ name: { type: String, required: true },
+ w: { type: Number, default: 13 },
+ h: { type: Number, default: 13 },
+ },
+ setup(props) {
+ const entry = computed(() => _iconMap[props.name] || null);
+ const vb = computed(() => entry.value ? entry.value.vb : "0 0 16 16");
+ const inner = computed(() => entry.value ? entry.value.inner : "");
+ return { vb, inner };
+ },
+ template: ` `,
+};
+
+/* ---- Chevron ---- */
+const Chevron = {
+ props: {
+ open: { type: Boolean, default: false },
+ },
+ template: `
+
+
+
+ `,
+};
+
+/* ---- FolderIcon ---- */
+const FolderIcon = {
+ props: {
+ open: { type: Boolean, default: false },
+ },
+ template: `
+
+
+
+ `,
+};
+
+/* ---- FileIcon ---- */
+const FileIcon = {
+ props: {
+ path: { type: String, required: true },
+ },
+ setup(props) {
+ const ic = computed(() => HL.iconFor(props.path));
+ return { ic };
+ },
+ template: `
+ {{ ic.t }}
+ `,
+};
+
+/* ---- GitRow ---- */
+const GitRow = {
+ props: {
+ c: { type: Object, required: true },
+ staged: { type: Boolean, required: true },
+ activePath: { type: String, default: null },
+ onOpen: { type: Function, required: true },
+ onContext: { type: Function, required: true },
+ onToggleStage: { type: Function, required: true },
+ },
+ components: { Icon, FileIcon },
+ setup(props) {
+ const name = computed(() => props.c.path.split("/").pop());
+ const dir = computed(() => props.c.path.split("/").slice(0, -1).join("/"));
+ return { name, dir };
+ },
+ template: `
+ onContext(e, { path: c.path, kind: 'git', staged })"
+ :title="c.path">
+ {{ c.status }}
+
+ {{ name }}
+ {{ dir }}/
+
+
+
+
+
+ +{{ c.add }}
+ -{{ c.del }}
+
+
+ `,
+};
+
+/* ---- GitPanel (multi-root fragment) ---- */
+const GitPanel = {
+ props: {
+ changes: { type: Array, required: true },
+ staged: { type: Object, required: true }, /* Set */
+ committed: { type: Object, required: true }, /* Set */
+ commitMsg: { type: String, required: true },
+ setCommitMsg: { type: Function, required: true },
+ onStage: { type: Function, required: true },
+ onUnstage: { type: Function, required: true },
+ onStageAll: { type: Function, required: true },
+ onUnstageAll: { type: Function, required: true },
+ onCommit: { type: Function, required: true },
+ onOpen: { type: Function, required: true },
+ onContext: { type: Function, required: true },
+ activePath: { type: String, default: null },
+ },
+ components: { Icon, GitRow },
+ setup(props) {
+ const visible = computed(() => props.changes.filter((c) => !props.committed.has(c.path)));
+ const stagedList = computed(() => visible.value.filter((c) => props.staged.has(c.path)));
+ const changesList = computed(() => visible.value.filter((c) => !props.staged.has(c.path)));
+ const totals = computed(() =>
+ visible.value.reduce((a, c) => ({ add: a.add + c.add, del: a.del + c.del }), { add: 0, del: 0 })
+ );
+ const canCommit = computed(() => stagedList.value.length > 0 && props.commitMsg.trim().length > 0);
+
+ function handleKeyDown(e) {
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter" && canCommit.value) {
+ e.preventDefault();
+ props.onCommit();
+ }
+ }
+
+ return { visible, stagedList, changesList, totals, canCommit, handleKeyDown };
+ },
+ /* Vue 3 allows multi-root templates */
+ template: `
+
+ Source Control
+ {{ visible.length }}
+
+
+
+
+
+ Commit{{ stagedList.length ? ' ' + stagedList.length : '' }}
+
+
+
+
+
+ No changes — working tree clean
+
+
+
+ Staged Changes {{ stagedList.length }}
+
+
+
+
+
+
+
+ Nothing staged — use + to stage a file
+
+
+
+
+ Changes {{ changesList.length }}
+
+
+
+
+
+
+
+ All changes staged
+
+
+
+
+ `,
+};
+
+/* ---- TreeNode (recursive — registered globally) ---- */
+const TreeNode = {
+ name: "TreeNode",
+ props: {
+ node: { type: Object, required: true },
+ depth: { type: Number, required: true },
+ openDirs: { type: Object, required: true }, /* Set */
+ toggleDir: { type: Function, required: true },
+ onOpen: { type: Function, required: true },
+ onContext: { type: Function, required: true },
+ activePath: { type: String, default: null },
+ changeMap: { type: Object, required: true },
+ committed: { type: Object, default: null }, /* Set or null */
+ },
+ components: { Chevron, FolderIcon, FileIcon },
+ setup(props) {
+ const pad = computed(() => 10 + props.depth * 13);
+ const isOpen = computed(() => props.node.type === "dir" && (props.openDirs.has(props.node.path) || props.node.path === ""));
+ const status = computed(() => {
+ if (props.node.type === "dir") return null;
+ return (props.committed && props.committed.has(props.node.path)) ? null : props.changeMap[props.node.path];
+ });
+ return { pad, isOpen, status };
+ },
+ /* Multi-root fragment for the dir case */
+ template: `
+
+ onContext(e, { path: node.path, kind: 'dir' })">
+
+
+ {{ node.name }}
+
+
+
+
+
+ onContext(e, { path: node.path, kind: 'file' })"
+ :title="node.path">
+
+
+
+ {{ node.name }}
+
+ {{ status }}
+
+ `,
+};
+
+/* Self-reference: register TreeNode in itself for recursion */
+TreeNode.components = Object.assign(TreeNode.components || {}, { TreeNode });
+
+/* ---- FileTree (multi-root fragment) ---- */
+const FileTree = {
+ props: {
+ tree: { type: Object, required: true },
+ openDirs: { type: Object, required: true }, /* Set */
+ toggleDir: { type: Function, required: true },
+ onOpen: { type: Function, required: true },
+ onContext: { type: Function, required: true },
+ activePath: { type: String, default: null },
+ changeMap: { type: Object, required: true },
+ committed: { type: Object, default: null }, /* Set or null */
+ },
+ components: { TreeNode },
+ template: `
+
+ Explorer
+ {{ tree.name }}
+
+
+
+
+ `,
+};
+
+/* ---- Global export ---- */
+window.HelderComponents = Object.assign(window.HelderComponents || {}, {
+ Icon,
+ Chevron,
+ FolderIcon,
+ FileIcon,
+ GitRow,
+ GitPanel,
+ TreeNode,
+ FileTree,
+});
diff --git a/design_handoff_helder_workbench/design-vue/src/data.js b/design_handoff_helder_workbench/design-vue/src/data.js
new file mode 100644
index 0000000..a9caaaf
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/data.js
@@ -0,0 +1,712 @@
+/* Mock project: filesystem tree, file contents, before/after pairs, runtime diff. */
+(function () {
+ // ---- working-tree (current / updated) file contents ----------------
+ const F = {};
+
+ F["src/Http/Controller/UserController.php"] = `users->find($id);
+
+ if ($user === null) {
+ return $this->json(['error' => 'user_not_found'], 404);
+ }
+
+ $balance = $this->payments->balanceFor($user);
+
+ return $this->json([
+ 'id' => $user->id,
+ 'email' => $user->email,
+ 'plan' => $user->plan->value,
+ 'name' => $user->name,
+ 'currency' => $user->currency,
+ 'balance' => $balance->toArray(),
+ ]);
+ }
+
+ public function update(ServerRequestInterface $request, string $id): ResponseInterface
+ {
+ $user = $this->users->find($id);
+ if ($user === null) {
+ return $this->json(['error' => 'user_not_found'], 404);
+ }
+
+ $data = (array) $request->getParsedBody();
+ $user->fill($this->onlyFillable($data));
+ $this->users->save($user);
+
+ return $this->json($user->toArray());
+ }
+
+ /** @return array */
+ private function onlyFillable(array $data): array
+ {
+ $allowed = ['email', 'plan', 'name'];
+ return array_intersect_key($data, array_flip($allowed));
+ }
+}
+`;
+
+ F["src/Service/PaymentService.php"] = `gateway->lookupBalance($user->id);
+ return Money::fromCents($cents, $user->currency ?? 'EUR');
+ }
+
+ public function charge(User $user, Money $amount, string $reason): bool
+ {
+ if ($amount->isZero()) {
+ $this->logger->warning('Skipped zero charge', ['user' => $user->id]);
+ return false;
+ }
+
+ $result = $this->gateway->charge($user->paymentToken, $amount->cents());
+ $this->logger->info('Charge attempt', [
+ 'user' => $user->id,
+ 'amount' => $amount->cents(),
+ 'ok' => $result->success,
+ ]);
+
+ return $result->success;
+ }
+}
+`;
+
+ F["public/assets/app.js"] = `import { createStore } from './store.js';
+import { mountRouter } from './router.js';
+
+const store = createStore({
+ user: null,
+ notifications: [],
+ theme: 'dark',
+});
+
+async function bootstrap() {
+ const res = await fetch('/api/session', { credentials: 'include' });
+ if (res.ok) {
+ const session = await res.json();
+ store.set('user', session.user);
+ store.set('theme', session.user.theme ?? 'dark');
+ }
+
+ mountRouter(document.querySelector('#app'), store);
+ store.subscribe('notifications', renderToasts);
+}
+
+function renderToasts(list) {
+ const host = document.querySelector('#toasts');
+ host.replaceChildren(...list.map((n) => {
+ const el = document.createElement('div');
+ el.className = \\\`toast toast--\\\${n.level}\\\`;
+ el.textContent = n.message;
+ return el;
+ }));
+}
+
+document.addEventListener('DOMContentLoaded', bootstrap);
+`;
+
+ F["public/assets/store.js"] = `export function createStore(initial = {}) {
+ let state = { ...initial };
+ const subs = new Map();
+
+ return {
+ get: (key) => state[key],
+ set(key, value) {
+ state = { ...state, [key]: value };
+ (subs.get(key) || []).forEach((fn) => fn(value, state));
+ },
+ subscribe(key, fn) {
+ const list = subs.get(key) || [];
+ list.push(fn);
+ subs.set(key, list);
+ return () => subs.set(key, list.filter((f) => f !== fn));
+ },
+ };
+}
+`;
+
+ F["public/assets/styles.css"] = `:root {
+ --brand: #4d8dff;
+ --ink: #15171a;
+ --paper: #ffffff;
+ --radius: 10px;
+}
+
+body {
+ margin: 0;
+ font-family: system-ui, sans-serif;
+ background: var(--ink);
+ color: #e6e8ea;
+}
+
+.toast {
+ padding: 10px 14px;
+ border-radius: var(--radius);
+ border-left: 3px solid var(--brand);
+}
+
+.toast--error { border-left-color: #e0696a; }
+.toast--success { border-left-color: #5cbd6b; }
+`;
+
+ F["src/types/api.ts"] = `export type Plan = 'free' | 'pro' | 'enterprise';
+
+export interface User {
+ id: string;
+ email: string;
+ name: string;
+ plan: Plan;
+ currency: string;
+ createdAt: string;
+}
+
+export interface Balance {
+ cents: number;
+ currency: string;
+ formatted: string;
+}
+
+export type ApiResult =
+ | { ok: true; data: T }
+ | { ok: false; error: string; status: number };
+
+export async function getUser(id: string): Promise> {
+ const res = await fetch(\\\`/api/users/\\\${id}\\\`);
+ if (!res.ok) {
+ return { ok: false, error: 'request_failed', status: res.status };
+ }
+ return { ok: true, data: (await res.json()) as User };
+}
+`;
+
+ F["scripts/migrate.py"] = `#!/usr/bin/env python3
+"""Run pending database migrations in order."""
+import sys
+from pathlib import Path
+
+from db import connect, applied_migrations
+
+MIGRATIONS = Path(__file__).parent / "migrations"
+
+
+def pending(conn):
+ done = applied_migrations(conn)
+ files = sorted(MIGRATIONS.glob("*.sql"))
+ return [f for f in files if f.stem not in done]
+
+
+def run(conn, migration: Path) -> None:
+ sql = migration.read_text()
+ print(f" -> applying {migration.stem}")
+ with conn.cursor() as cur:
+ cur.execute(sql)
+ cur.execute(
+ "INSERT INTO schema_migrations (version) VALUES (%s)",
+ (migration.stem,),
+ )
+ conn.commit()
+
+
+def main() -> int:
+ conn = connect()
+ todo = pending(conn)
+ if not todo:
+ print("Database is up to date.")
+ return 0
+ print(f"Applying {len(todo)} migration(s)...")
+ for migration in todo:
+ run(conn, migration)
+ print("Done.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+`;
+
+ F["scripts/seed.py"] = `#!/usr/bin/env python3
+"""Seed the database with demo data for local development."""
+import random
+from db import connect
+
+PLANS = ["free", "pro", "enterprise"]
+
+
+def seed_users(conn, count: int = 25) -> None:
+ with conn.cursor() as cur:
+ for i in range(count):
+ cur.execute(
+ "INSERT INTO users (email, plan) VALUES (%s, %s)",
+ (f"user{i}@example.com", random.choice(PLANS)),
+ )
+ conn.commit()
+ print(f"Seeded {count} users.")
+
+
+if __name__ == "__main__":
+ seed_users(connect())
+`;
+
+ F["templates/dashboard.html"] = `
+
+
+
+
+ Dashboard
+
+
+
+
+
+
+
+
+
+
+
+`;
+
+ F["config/app.json"] = `{
+ "name": "console",
+ "env": "production",
+ "features": {
+ "billing": true,
+ "newDashboard": true,
+ "exportCsv": false
+ },
+ "payment": {
+ "gateway": "stripe",
+ "currency": "EUR",
+ "retryLimit": 3
+ },
+ "logging": {
+ "level": "info",
+ "channel": "stdout"
+ }
+}
+`;
+
+ F["composer.json"] = `{
+ "name": "blijnder/console",
+ "type": "project",
+ "require": {
+ "php": ">=8.2",
+ "psr/log": "^3.0",
+ "psr/http-message": "^2.0",
+ "nyholm/psr7": "^1.8"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11.0",
+ "phpstan/phpstan": "^1.11"
+ },
+ "autoload": {
+ "psr-4": { "App\\\\": "src/" }
+ }
+}
+`;
+
+ F["package.json"] = `{
+ "name": "console-frontend",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "test": "vitest run",
+ "lint": "eslint ."
+ },
+ "devDependencies": {
+ "vite": "^5.3.0",
+ "vitest": "^2.0.0",
+ "typescript": "^5.5.0"
+ }
+}
+`;
+
+ F["README.md"] = `# Console
+
+Internal admin console. PHP API + small vanilla JS frontend.
+
+## Getting started
+
+ composer install
+ npm install
+ python scripts/migrate.py
+ npm run dev
+
+## Layout
+
+- \`src/\` PHP application code (PSR-4, \`App\\\` namespace)
+- \`public/\` Document root and frontend assets
+- \`scripts/\` Python maintenance + migration scripts
+- \`templates/\` Server-rendered HTML
+`;
+
+ F[".env"] = `APP_ENV=production
+APP_DEBUG=false
+DATABASE_URL=postgres://localhost:5432/console
+PAYMENT_GATEWAY=stripe
+PAYMENT_CURRENCY=EUR
+LOG_LEVEL=info
+`;
+
+ // ---- ORIGINAL (pre-edit) versions of changed files ----------------
+ const O = {};
+
+ O["src/Http/Controller/UserController.php"] = `users->find($id);
+
+ if ($user === null) {
+ return $this->json(['error' => 'user_not_found'], 404);
+ }
+
+ $balance = $this->payments->balanceFor($user);
+
+ return $this->json([
+ 'id' => $user->id,
+ 'email' => $user->email,
+ 'plan' => $user->plan,
+ 'balance' => $balance->toArray(),
+ ]);
+ }
+
+ public function update(ServerRequestInterface $request, string $id): ResponseInterface
+ {
+ $user = $this->users->find($id);
+ if ($user === null) {
+ return $this->json(['error' => 'user_not_found'], 404);
+ }
+
+ $data = (array) $request->getParsedBody();
+ $user->fill($this->onlyFillable($data));
+ $this->users->save($user);
+
+ return $this->json($user->toArray());
+ }
+
+ private function onlyFillable(array $data): array
+ {
+ return array_intersect_key($data, array_flip(['email', 'plan']));
+ }
+}
+`;
+
+ O["public/assets/app.js"] = `import { createStore } from './store.js';
+import { mountRouter } from './router.js';
+
+const store = createStore({
+ user: null,
+ notifications: [],
+ theme: 'dark',
+});
+
+async function bootstrap() {
+ const res = await fetch('/api/session');
+ if (res.ok) {
+ const session = await res.json();
+ store.set('user', session.user);
+ }
+
+ mountRouter(document.querySelector('#app'), store);
+ store.subscribe('notifications', renderToasts);
+}
+
+function renderToasts(list) {
+ const host = document.querySelector('#toasts');
+ host.replaceChildren(...list.map((n) => {
+ const el = document.createElement('div');
+ el.className = \\\`toast toast--\\\${n.level}\\\`;
+ el.textContent = n.message;
+ return el;
+ }));
+}
+
+document.addEventListener('DOMContentLoaded', bootstrap);
+`;
+
+ O["config/app.json"] = `{
+ "name": "console",
+ "env": "production",
+ "features": {
+ "billing": true,
+ "newDashboard": false
+ },
+ "payment": {
+ "gateway": "stripe",
+ "currency": "EUR",
+ "retryLimit": 3
+ },
+ "logging": {
+ "level": "info",
+ "channel": "stdout"
+ }
+}
+`;
+
+ O["scripts/migrate.py"] = `#!/usr/bin/env python3
+"""Run pending database migrations in order."""
+import sys
+from pathlib import Path
+
+from db import connect, applied_migrations
+
+MIGRATIONS = Path(__file__).parent / "migrations"
+
+
+def pending(conn):
+ done = applied_migrations(conn)
+ files = sorted(MIGRATIONS.glob("*.sql"))
+ return [f for f in files if f.stem not in done]
+
+
+def run(conn, migration: Path) -> None:
+ sql = migration.read_text()
+ print(f" -> applying {migration.stem}")
+ with conn.cursor() as cur:
+ cur.execute(sql)
+ cur.execute(
+ "INSERT INTO schema_migrations (version) VALUES (%s)",
+ (migration.stem,),
+ )
+ conn.commit()
+
+
+def main() -> int:
+ conn = connect()
+ todo = pending(conn)
+ if not todo:
+ print("Database is up to date.")
+ return 0
+ for migration in todo:
+ run(conn, migration)
+ print("Done.")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
+`;
+
+ // PaymentService is a brand-new file (added) -> original is empty
+ O["src/Service/PaymentService.php"] = "";
+
+ // LegacyUser was deleted -> original content, no working-tree version
+ O["src/Model/LegacyUser.php"] = ` $this->id,
+ 'email' => $this->email,
+ 'plan' => $this->plan,
+ ];
+ }
+}
+`;
+
+ // ---- file tree (nested) -------------------------------------------
+ const tree = {
+ name: "console", type: "dir", path: "", open: true, children: [
+ { name: "config", type: "dir", path: "config", open: false, children: [
+ { name: "app.json", type: "file", path: "config/app.json" },
+ ]},
+ { name: "public", type: "dir", path: "public", open: true, children: [
+ { name: "assets", type: "dir", path: "public/assets", open: true, children: [
+ { name: "app.js", type: "file", path: "public/assets/app.js" },
+ { name: "store.js", type: "file", path: "public/assets/store.js" },
+ { name: "styles.css", type: "file", path: "public/assets/styles.css" },
+ ]},
+ ]},
+ { name: "scripts", type: "dir", path: "scripts", open: false, children: [
+ { name: "migrate.py", type: "file", path: "scripts/migrate.py" },
+ { name: "seed.py", type: "file", path: "scripts/seed.py" },
+ ]},
+ { name: "src", type: "dir", path: "src", open: true, children: [
+ { name: "Http", type: "dir", path: "src/Http", open: true, children: [
+ { name: "Controller", type: "dir", path: "src/Http/Controller", open: true, children: [
+ { name: "UserController.php", type: "file", path: "src/Http/Controller/UserController.php" },
+ ]},
+ ]},
+ { name: "Service", type: "dir", path: "src/Service", open: true, children: [
+ { name: "PaymentService.php", type: "file", path: "src/Service/PaymentService.php" },
+ ]},
+ { name: "types", type: "dir", path: "src/types", open: false, children: [
+ { name: "api.ts", type: "file", path: "src/types/api.ts" },
+ ]},
+ ]},
+ { name: "templates", type: "dir", path: "templates", open: false, children: [
+ { name: "dashboard.html", type: "file", path: "templates/dashboard.html" },
+ ]},
+ { name: ".env", type: "file", path: ".env" },
+ { name: "composer.json", type: "file", path: "composer.json" },
+ { name: "package.json", type: "file", path: "package.json" },
+ { name: "README.md", type: "file", path: "README.md" },
+ ],
+ };
+
+ // ---- line-based LCS diff ------------------------------------------
+ function buildDiff(origText, updText) {
+ const a = origText === "" ? [] : origText.replace(/\n$/, "").split("\n");
+ const b = updText === "" ? [] : updText.replace(/\n$/, "").split("\n");
+ const n = a.length, m = b.length;
+ const dp = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
+ for (let i = n - 1; i >= 0; i--)
+ for (let j = m - 1; j >= 0; j--)
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
+
+ const ops = [];
+ let i = 0, j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) { ops.push({ t: "same", a: i, b: j }); i++; j++; }
+ else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ t: "del", a: i }); i++; }
+ else { ops.push({ t: "add", b: j }); j++; }
+ }
+ while (i < n) { ops.push({ t: "del", a: i++ }); }
+ while (j < m) { ops.push({ t: "add", b: j++ }); }
+
+ const rows = [], left = [], right = [], split = [];
+ const delSet = new Set(), addSet = new Set();
+ let add = 0, del = 0;
+ for (const op of ops) {
+ if (op.t === "same") {
+ rows.push({ sign: " ", oldNo: op.a + 1, newNo: op.b + 1, text: a[op.a] });
+ } else if (op.t === "del") {
+ rows.push({ sign: "-", oldNo: op.a + 1, newNo: null, text: a[op.a] });
+ delSet.add(op.a); del++;
+ } else {
+ rows.push({ sign: "+", oldNo: null, newNo: op.b + 1, text: b[op.b] });
+ addSet.add(op.b); add++;
+ }
+ }
+ a.forEach((text, idx) => left.push({ no: idx + 1, text, mark: delSet.has(idx) ? "del" : null }));
+ b.forEach((text, idx) => right.push({ no: idx + 1, text, mark: addSet.has(idx) ? "add" : null }));
+
+ // aligned split rows (pair del/add blocks)
+ let dbuf = [], abuf = [];
+ const flush = () => {
+ const k = Math.max(dbuf.length, abuf.length);
+ for (let x = 0; x < k; x++) split.push({ l: dbuf[x] || null, r: abuf[x] || null });
+ dbuf = []; abuf = [];
+ };
+ for (const op of ops) {
+ if (op.t === "same") { flush(); split.push({ l: { no: op.a + 1, text: a[op.a] }, r: { no: op.b + 1, text: b[op.b] } }); }
+ else if (op.t === "del") dbuf.push({ no: op.a + 1, text: a[op.a], mark: "del" });
+ else abuf.push({ no: op.b + 1, text: b[op.b], mark: "add" });
+ }
+ flush();
+
+ return { rows, left, right, split, add, del };
+ }
+
+ // ---- changed files -------------------------------------------------
+ const changeDefs = [
+ { path: "src/Service/PaymentService.php", status: "A" },
+ { path: "src/Http/Controller/UserController.php", status: "M" },
+ { path: "public/assets/app.js", status: "M" },
+ { path: "config/app.json", status: "M" },
+ { path: "scripts/migrate.py", status: "M" },
+ { path: "src/Model/LegacyUser.php", status: "D" },
+ ];
+
+ const diffs = {};
+ const changes = changeDefs.map((c) => {
+ const orig = O[c.path] != null ? O[c.path] : "";
+ const upd = F[c.path] != null ? F[c.path] : "";
+ const d = buildDiff(orig, upd);
+ diffs[c.path] = Object.assign(d, {
+ deleted: c.status === "D",
+ added: c.status === "A",
+ original: orig,
+ updated: upd,
+ });
+ return { path: c.path, status: c.status, add: d.add, del: d.del, deleted: c.status === "D" };
+ });
+
+ window.PROJECT = {
+ name: "console",
+ branch: "feat/payments-balance",
+ files: F,
+ originals: O,
+ tree,
+ diffs,
+ changes,
+ };
+})();
diff --git a/design_handoff_helder_workbench/design-vue/src/editor.js b/design_handoff_helder_workbench/design-vue/src/editor.js
new file mode 100644
index 0000000..2afc52a
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/editor.js
@@ -0,0 +1,331 @@
+/* Editor: tabs + four view modes (Original / Updated / Diff / Split) + line selection */
+
+const { ref, computed, onMounted, nextTick, watch } = Vue;
+
+/* ---------- framework-agnostic helpers (ported verbatim) ---------- */
+
+function climbToLine(node) {
+ let el = node && node.nodeType === 3 ? node.parentElement : node;
+ while (el && !(el.dataset && el.dataset.line)) el = el.parentElement;
+ return el || null;
+}
+
+function buildLines(mode, diff, fileText) {
+ if (mode === "original") return { lines: diff.left.map((l) => ({ no: l.no, text: l.text, row: l.mark === "del" ? "bar-del" : null })), showSign: false };
+ if (mode === "updated") return { lines: diff.right.map((l) => ({ no: l.no, text: l.text, row: l.mark === "add" ? "bar-add" : null })), showSign: false };
+ if (mode === "diff") return { lines: diff.rows.map((r) => ({ no: r.newNo || r.oldNo, text: r.text, sign: r.sign, row: r.sign === "+" ? "add" : r.sign === "-" ? "del" : null })), showSign: true };
+ // plain file
+ const arr = (fileText || "").replace(/\n$/, "").split("\n");
+ return { lines: arr.map((t, i) => ({ no: i + 1, text: t })), showSign: false };
+}
+
+const SEGMENTS = [
+ { id: "original", label: "Original" },
+ { id: "updated", label: "Updated" },
+ { id: "diff", label: "Diff" },
+];
+
+/* ---------- EditorTabs ---------- */
+
+const EditorTabs = {
+ props: ["tabs", "active", "onActivate", "onClose"],
+ setup(props) {
+ const rootRef = ref(null);
+
+ watch(() => props.active, async () => {
+ await nextTick();
+ const el = rootRef.value && rootRef.value.querySelector(".tab.active");
+ if (el) el.scrollIntoView({ block: "nearest", inline: "nearest" });
+ });
+
+ return { rootRef };
+ },
+ template: `
+
+
{ if (e.button === 1) { e.preventDefault(); onClose(t.path); } }"
+ :title="t.path"
+ >
+
+ {{ t.path.split('/').pop() }}
+ {{ t.modeLabel }}
+
+
+
+
+
+ `,
+};
+
+/* ---------- PaneView ---------- */
+
+const PaneView = {
+ props: ["cacheKey", "path", "lines", "lang", "showSign", "refLine", "cursor", "selection", "setCursor", "setSelection", "onContext"],
+ setup(props) {
+ // anchor for shift-click range selection
+ const anchorRef = ref(null);
+
+ // highlighted HTML — recomputed when cacheKey changes
+ const html = computed(() => props.lines.map((l) => HL.hlLine(l.text, props.lang)));
+
+ function gutterClick(e, no) {
+ if (no == null) return;
+ e.stopPropagation();
+ if (e.shiftKey && anchorRef.value != null) {
+ const a = anchorRef.value;
+ props.setSelection({ path: props.path, start: Math.min(a, no), end: Math.max(a, no), anchor: a });
+ } else {
+ anchorRef.value = no;
+ props.setSelection({ path: props.path, start: no, end: no, anchor: no });
+ }
+ props.setCursor({ path: props.path, line: no, col: 1 });
+ }
+
+ function caretCol(sel) {
+ try {
+ const el = climbToLine(sel.focusNode);
+ const code = el.querySelector(".ln-code");
+ const r = document.createRange();
+ r.setStart(code, 0); r.setEnd(sel.focusNode, sel.focusOffset);
+ return r.toString().length + 1;
+ } catch (e) { return 1; }
+ }
+
+ function onMouseUp() {
+ const sel = window.getSelection();
+ if (sel && !sel.isCollapsed) {
+ const a = climbToLine(sel.anchorNode), f = climbToLine(sel.focusNode);
+ if (a && f) {
+ const an = +a.dataset.line, fn = +f.dataset.line;
+ const s = Math.min(an, fn), e = Math.max(an, fn);
+ if (s !== e) { props.setSelection({ path: props.path, start: s, end: e, anchor: an }); props.setCursor({ path: props.path, line: fn, col: caretCol(sel) }); return; }
+ }
+ }
+ if (sel && sel.focusNode) {
+ const el = climbToLine(sel.focusNode);
+ if (el) { props.setCursor({ path: props.path, line: +el.dataset.line, col: caretCol(sel) }); props.setSelection(null); }
+ }
+ }
+
+ function handleContext(e) {
+ e.preventDefault();
+ const sel = window.getSelection();
+ let info = { path: props.path, kind: "editor" };
+ const a = sel && sel.anchorNode && climbToLine(sel.anchorNode);
+ const f = sel && sel.focusNode && climbToLine(sel.focusNode);
+ if (sel && !sel.isCollapsed && a && f && +a.dataset.line !== +f.dataset.line) {
+ const s = Math.min(+a.dataset.line, +f.dataset.line), en = Math.max(+a.dataset.line, +f.dataset.line);
+ info.sel = { start: s, end: en }; info.line = s;
+ } else if (props.selection && props.selection.path === props.path && props.selection.start !== props.selection.end) {
+ info.sel = { start: props.selection.start, end: props.selection.end }; info.line = props.selection.start;
+ } else {
+ let no = null;
+ const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
+ const el = r && climbToLine(r.startContainer);
+ if (el) no = +el.dataset.line;
+ info.line = no || (props.cursor && props.cursor.path === props.path ? props.cursor.line : 1);
+ }
+ props.setCursor({ path: props.path, line: info.line, col: 1 });
+ props.onContext(e, info);
+ }
+
+ return { html, gutterClick, onMouseUp, handleContext };
+ },
+ template: `
+
+
+
+ gutterClick(e, l.no)">{{ l.no == null ? '' : l.no }}
+ {{ l.sign === ' ' || !l.sign ? '' : l.sign }}
+
+
+
+
+ `,
+};
+
+/* ---------- Editor ---------- */
+
+const Editor = {
+ props: ["tabs", "active", "mode", "setMode", "onActivate", "onClose", "onContext", "onSplit", "splitOpen", "cursor", "selection", "setCursor", "setSelection"],
+ setup(props) {
+ const tab = computed(() => props.tabs.find((t) => t.path === props.active));
+ const change = computed(() => tab.value ? PROJECT.changes.find((c) => c.path === tab.value.path) : null);
+ const diff = computed(() => tab.value ? PROJECT.diffs[tab.value.path] : null);
+ const lang = computed(() => tab.value ? HL.langFor(tab.value.path) : null);
+ const effMode = computed(() => change.value ? props.mode : "code");
+
+ const built = computed(() => {
+ if (!tab.value) return null;
+ if (change.value && diff.value) return buildLines(effMode.value, diff.value, PROJECT.files[tab.value.path]);
+ return buildLines("code", null, PROJECT.files[tab.value.path]);
+ });
+
+ const statusWord = computed(() => {
+ if (!change.value) return "";
+ return change.value.status === "A" ? "Added" : change.value.status === "D" ? "Deleted" : "Modified";
+ });
+ const activeSeg = computed(() => props.splitOpen ? "split" : effMode.value);
+ const emptyUpdated = computed(() => effMode.value === "updated" && built.value && built.value.lines.length === 0);
+ const emptyOriginal = computed(() => effMode.value === "original" && built.value && built.value.lines.length === 0);
+
+ return { tab, change, diff, lang, effMode, built, statusWord, activeSeg, emptyUpdated, emptyOriginal, SEGMENTS };
+ },
+ template: `
+
+
+
+
+
No file open
+
+
Search files & content ⌘ F
+
Copy reference right-click
+
Pass on to Agent right-click
+
+
+
+
+
+
{{ statusWord }}
+
+{{ change.add }}
+
−{{ change.del }}
+
+ {{ s.label }}
+
+
+ Split
+
+
+
+
+
+
No updated version
+
This file was deleted in the change.
+
+
+
No original version
+
This file is new in the change.
+
+
+
+ `,
+};
+
+/* ---------- SplitView ---------- */
+
+const SplitView = {
+ props: ["path", "onClose", "onContext"],
+ setup(props) {
+ const diff = computed(() => PROJECT.diffs[props.path]);
+ const lang = computed(() => HL.langFor(props.path));
+ const change = computed(() => PROJECT.changes.find((c) => c.path === props.path));
+
+ const leftRef = ref(null);
+ const rightRef = ref(null);
+ const lock = ref(false);
+
+ const leftHtml = computed(() => diff.value.split.map((r) => r.l ? HL.hlLine(r.l.text, lang.value) : ""));
+ const rightHtml = computed(() => diff.value.split.map((r) => r.r ? HL.hlLine(r.r.text, lang.value) : ""));
+
+ function sync(from, to) {
+ if (lock.value) return; lock.value = true;
+ to.scrollTop = from.scrollTop;
+ to.scrollLeft = from.scrollLeft;
+ requestAnimationFrame(() => { lock.value = false; });
+ }
+
+ function ctx(e, side) {
+ e.preventDefault();
+ let no = null;
+ const r = document.caretRangeFromPoint ? document.caretRangeFromPoint(e.clientX, e.clientY) : null;
+ const el = r && climbToLine(r.startContainer);
+ if (el) no = +el.dataset.line;
+ props.onContext(e, { path: props.path, kind: "editor", line: no || 1 });
+ }
+
+ return { diff, lang, change, leftRef, rightRef, sync, ctx, leftHtml, rightHtml };
+ },
+ template: `
+
+
+
+
{{ path }}
+
{{ change.status === 'A' ? 'Added' : change.status === 'D' ? 'Deleted' : 'Modified' }}
+
+{{ change.add }}
+
−{{ change.del }}
+
+
+ Collapse Esc
+
+
+
+
+
Original before
+
ctx(e, 'l')">
+
+ {{ row.l ? row.l.no : '' }}
+
+
+
+
+
+
Updated after
+
ctx(e, 'r')">
+
+ {{ row.r ? row.r.no : '' }}
+
+
+
+
+
+
+ `,
+};
+
+/* ---------- global registration ---------- */
+
+window.HelderComponents = Object.assign(window.HelderComponents || {}, { EditorTabs, PaneView, Editor, SplitView });
+window.buildLines = buildLines;
+window.climbToLine = climbToLine;
diff --git a/design_handoff_helder_workbench/design-vue/src/highlight.js b/design_handoff_helder_workbench/design-vue/src/highlight.js
new file mode 100644
index 0000000..6143b9c
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/highlight.js
@@ -0,0 +1,79 @@
+/* Syntax highlighting (Prism) + file-type icon metadata. */
+(function () {
+ const EXT_LANG = {
+ php: "php", js: "javascript", mjs: "javascript", cjs: "javascript",
+ jsx: "jsx", ts: "typescript", tsx: "tsx", py: "python",
+ html: "markup", xml: "markup", svg: "markup", vue: "markup",
+ css: "css", scss: "css", json: "json", md: "markdown",
+ sh: "bash", bash: "bash", yml: "yaml", yaml: "yaml", env: "bash",
+ };
+
+ function ext(path) {
+ const base = path.split("/").pop() || "";
+ if (base === ".env" || base.startsWith(".env")) return "env";
+ const i = base.lastIndexOf(".");
+ return i >= 0 ? base.slice(i + 1).toLowerCase() : "";
+ }
+
+ function langFor(path) { return EXT_LANG[ext(path)] || null; }
+
+ function langLabel(path) {
+ const e = ext(path);
+ const map = {
+ php: "PHP", js: "JavaScript", mjs: "JavaScript", ts: "TypeScript",
+ tsx: "TypeScript", jsx: "JavaScript", py: "Python", html: "HTML",
+ css: "CSS", json: "JSON", md: "Markdown", sh: "Shell", env: "Dotenv",
+ yml: "YAML", yaml: "YAML",
+ };
+ return map[e] || (e ? e.toUpperCase() : "Plain Text");
+ }
+
+ function escapeHtml(s) {
+ return s.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c]));
+ }
+
+ // highlight a single line independently (keeps line numbering robust)
+ function hlLine(line, lang) {
+ if (line === "") return " ";
+ try {
+ const grammar = lang && window.Prism && Prism.languages[lang];
+ if (grammar) return Prism.highlight(line, grammar, lang);
+ } catch (e) { /* fall through */ }
+ return escapeHtml(line);
+ }
+
+ // ---- file-type icon: colored monogram chip --------------------------
+ const ICONS = {
+ php: { c: "#a78bdb", t: "php" },
+ js: { c: "#e6c860", t: "js" },
+ mjs: { c: "#e6c860", t: "js" },
+ ts: { c: "#5a9bd6", t: "ts" },
+ tsx: { c: "#5a9bd6", t: "ts" },
+ jsx: { c: "#5a9bd6", t: "jsx" },
+ py: { c: "#5fa8d6", t: "py" },
+ html: { c: "#e08b6a", t: "<>" },
+ css: { c: "#5a9bd6", t: "{}" },
+ scss: { c: "#d6699e", t: "{}" },
+ json: { c: "#d8a85c", t: "{}" },
+ md: { c: "#9aa0a8", t: "md" },
+ env: { c: "#7fc6a0", t: "$" },
+ sh: { c: "#7fc6a0", t: "$" },
+ yml: { c: "#cf7a6a", t: "yml" },
+ yaml: { c: "#cf7a6a", t: "yml" },
+ lock: { c: "#8a8f98", t: "lk" },
+ };
+ const NAME_ICONS = {
+ "composer.json": { c: "#a78bdb", t: "co" },
+ "package.json": { c: "#cf7a6a", t: "pk" },
+ "README.md": { c: "#5a9bd6", t: "md" },
+ ".env": { c: "#7fc6a0", t: "$" },
+ };
+
+ function iconFor(path) {
+ const base = path.split("/").pop() || "";
+ if (NAME_ICONS[base]) return NAME_ICONS[base];
+ return ICONS[ext(path)] || { c: "#7d838c", t: base.slice(0, 2) || "·" };
+ }
+
+ window.HL = { ext, langFor, langLabel, hlLine, iconFor, escapeHtml };
+})();
diff --git a/design_handoff_helder_workbench/design-vue/src/overlays.js b/design_handoff_helder_workbench/design-vue/src/overlays.js
new file mode 100644
index 0000000..55bb976
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/overlays.js
@@ -0,0 +1,332 @@
+/* Overlays: command palette (fuzzy file finder), content search, context menu, toast */
+
+const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
+
+// ---------------------------------------------------------------------------
+// fuzzy(q, str) — verbatim port
+// ---------------------------------------------------------------------------
+function fuzzy(q, str) {
+ q = q.toLowerCase(); const s = str.toLowerCase();
+ let i = 0; const idx = [];
+ for (let j = 0; j < s.length && i < q.length; j++) {
+ if (s[j] === q[i]) { idx.push(j); i++; }
+ }
+ return i === q.length ? idx : null;
+}
+
+// ---------------------------------------------------------------------------
+// Highlight — props: text, idx
+// ---------------------------------------------------------------------------
+const Highlight = {
+ props: ['text', 'idx'],
+ setup(props) {
+ // Build an array of { ch, bold } for rendering
+ const parts = computed(() => {
+ const set = props.idx && props.idx.length ? new Set(props.idx) : null;
+ return props.text.split('').map((ch, i) => ({ ch, bold: set ? set.has(i) : false }));
+ });
+ return { parts };
+ },
+ template: `
+
+
+ {{ p.ch }}
+ {{ p.ch }}
+
+
+ `
+};
+
+// ---------------------------------------------------------------------------
+// SearchModal — props: onOpen, onOpenAt, onClose, changeSet
+// ---------------------------------------------------------------------------
+const SearchModal = {
+ props: ['onOpen', 'onOpenAt', 'onClose', 'changeSet'],
+ setup(props) {
+ const q = ref('');
+ const sel = ref(0);
+ const inputEl = ref(null);
+ const leftEl = ref(null);
+
+ // All paths — stable, PROJECT.files doesn't change at runtime
+ const allPaths = computed(() => Object.keys(PROJECT.files));
+
+ // Content hits (left panel) — substring search, min 2 chars
+ const content = computed(() => {
+ const term = q.value.trim();
+ if (term.length < 2) return [];
+ const low = term.toLowerCase();
+ const groups = [];
+ for (const [path, src] of Object.entries(PROJECT.files)) {
+ const lines = src.split('\n');
+ const hits = [];
+ lines.forEach((ln, i) => {
+ const ix = ln.toLowerCase().indexOf(low);
+ if (ix >= 0) hits.push({ no: i + 1, ln, ix });
+ });
+ if (hits.length) groups.push({ path, hits });
+ }
+ return groups;
+ });
+
+ // File-name fuzzy matches (right panel)
+ const files = computed(() => {
+ const term = q.value.trim();
+ if (!term) return [];
+ const out = [];
+ for (const p of allPaths.value) {
+ const name = p.split('/').pop();
+ const ni = fuzzy(term, name);
+ if (ni) { out.push({ path: p, idx: ni, rank: 0, pos: ni[0] }); continue; }
+ const pi = fuzzy(term, p);
+ if (pi) out.push({ path: p, idx: null, rank: 1, pos: pi[0] });
+ }
+ out.sort((a, b) => a.rank - b.rank || a.pos - b.pos || a.path.length - b.path.length);
+ return out;
+ });
+
+ // Flat list of content hits for keyboard nav + flat-index tracking
+ // Each entry gets its flatIndex here so the template can use it directly
+ const flat = computed(() => {
+ const arr = [];
+ content.value.forEach((g) => g.hits.forEach((h) => arr.push({ path: g.path, no: h.no })));
+ return arr;
+ });
+
+ // Precomputed flat-index map: "path:lineNo" -> flatIndex
+ // Used in template to assign .sel class without mutating during render
+ const flatIndexMap = computed(() => {
+ const map = {};
+ let ix = 0;
+ content.value.forEach((g) => {
+ g.hits.slice(0, 12).forEach((h) => {
+ map[`${g.path}:${h.no}`] = ix++;
+ });
+ });
+ return map;
+ });
+
+ const totalHits = computed(() => flat.value.length);
+
+ // Reset selection when query changes
+ watch(q, () => { sel.value = 0; });
+
+ // Scroll selected line into view when sel changes
+ watch(sel, () => {
+ nextTick(() => {
+ const el = leftEl.value && leftEl.value.querySelector('.sr-line.sel');
+ if (el) el.scrollIntoView({ block: 'nearest' });
+ });
+ });
+
+ onMounted(() => { inputEl.value && inputEl.value.focus(); });
+
+ function onKey(e) {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ sel.value = Math.min(sel.value + 1, flat.value.length - 1);
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ sel.value = Math.max(sel.value - 1, 0);
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ if (flat.value[sel.value]) {
+ props.onOpenAt(flat.value[sel.value].path, flat.value[sel.value].no);
+ props.onClose();
+ } else if (files.value[0]) {
+ props.onOpen(files.value[0].path);
+ props.onClose();
+ }
+ } else if (e.key === 'Escape') {
+ e.preventDefault();
+ props.onClose();
+ }
+ }
+
+ // renderLine: returns { pre, mid, post } for a content line hit
+ function renderLine(ln, ix, len) {
+ return { pre: ln.slice(0, ix), mid: ln.slice(ix, ix + len), post: ln.slice(ix + len) };
+ }
+
+ return {
+ q, sel, inputEl, leftEl,
+ content, files, flat, flatIndexMap, totalHits,
+ onKey, renderLine,
+ };
+ },
+ template: `
+
+
+
+
+
+ {{ totalHits }} hit{{ totalHits === 1 ? '' : 's' }} · {{ files.length }} file{{ files.length === 1 ? '' : 's' }}
+
+
+
+
Content {{ totalHits }}
+
Type at least 2 characters
+
No content matches
+
+
+
+ {{ g.path }}
+ {{ g.hits.length }}
+
+
+ {{ h.no }}
+ {{ renderLine(h.ln, h.ix, q.trim().length).pre }}{{ renderLine(h.ln, h.ix, q.trim().length).mid }} {{ renderLine(h.ln, h.ix, q.trim().length).post }}
+
+
+
+
+
Files {{ files.length }}
+
Start typing…
+
No file names match
+
+
+
+
+ {{ r.path.split('/').slice(0,-1).join('/') }}/
+
+
•
+
+
+
+
+
+ `
+};
+
+// ---------------------------------------------------------------------------
+// ContextMenu — props: menu, onClose
+// ---------------------------------------------------------------------------
+const ContextMenu = {
+ props: ['menu', 'onClose'],
+ setup(props) {
+ const menuEl = ref(null);
+
+ function outsideClick(e) {
+ if (menuEl.value && !menuEl.value.contains(e.target)) props.onClose();
+ }
+ function escKey(e) {
+ if (e.key === 'Escape') props.onClose();
+ }
+
+ onMounted(() => {
+ document.addEventListener('mousedown', outsideClick);
+ document.addEventListener('keydown', escKey);
+ });
+ onUnmounted(() => {
+ document.removeEventListener('mousedown', outsideClick);
+ document.removeEventListener('keydown', escKey);
+ });
+
+ const pos = computed(() => {
+ if (!props.menu) return { x: 0, y: 0 };
+ return {
+ x: Math.min(props.menu.x, window.innerWidth - 270),
+ y: Math.min(props.menu.y, window.innerHeight - (props.menu.items.length * 34 + 60)),
+ };
+ });
+
+ return { menuEl, pos };
+ },
+ template: `
+
+
{{ menu.note }}
+
+
+
+
+ {{ it.label }}
+ {{ it.kbd }}
+
+
+
+ `
+};
+
+// ---------------------------------------------------------------------------
+// Toasts — props: toasts
+// ---------------------------------------------------------------------------
+const Toasts = {
+ props: ['toasts'],
+ template: `
+
+
+
+ {{ t.title }}
+ {{ t.ref }}
+
+
+ `
+};
+
+// ---------------------------------------------------------------------------
+// PassPopup — props: x, y, refStr, onConfirm, onCancel
+// ---------------------------------------------------------------------------
+const PassPopup = {
+ props: ['x', 'y', 'refStr', 'onConfirm', 'onCancel'],
+ setup(props) {
+ const text = ref('');
+ const inputEl = ref(null);
+ const boxEl = ref(null);
+
+ onMounted(() => { inputEl.value && inputEl.value.focus(); });
+
+ function outsideClick(e) {
+ if (boxEl.value && !boxEl.value.contains(e.target)) props.onCancel();
+ }
+ // Capture phase — matches original's addEventListener(…, true)
+ function escKey(e) {
+ if (e.key === 'Escape') { e.preventDefault(); props.onCancel(); }
+ }
+
+ onMounted(() => {
+ document.addEventListener('mousedown', outsideClick);
+ document.addEventListener('keydown', escKey, true);
+ });
+ onUnmounted(() => {
+ document.removeEventListener('mousedown', outsideClick);
+ document.removeEventListener('keydown', escKey, true);
+ });
+
+ const left = computed(() => Math.min(props.x, window.innerWidth - 360));
+ const top = computed(() => Math.min(props.y + 6, window.innerHeight - 150));
+
+ const preview = computed(() => (text.value.trim() ? text.value.trim() + ' ' : '') + props.refStr);
+
+ function onKeyDown(e) {
+ if (e.key === 'Enter') { e.preventDefault(); props.onConfirm(text.value); }
+ else if (e.key === 'Escape') { e.preventDefault(); props.onCancel(); }
+ }
+
+ return { text, inputEl, boxEl, left, top, preview, onKeyDown };
+ },
+ template: `
+
+
Pass on to Agent esc
+
+
inserts {{ preview }}
+
+
+ `
+};
+
+// ---------------------------------------------------------------------------
+// Expose
+// ---------------------------------------------------------------------------
+window.HelderComponents = Object.assign(window.HelderComponents || {}, {
+ Highlight, SearchModal, ContextMenu, Toasts, PassPopup,
+});
+window.fuzzy = fuzzy;
diff --git a/design_handoff_helder_workbench/design-vue/src/terminals.js b/design_handoff_helder_workbench/design-vue/src/terminals.js
new file mode 100644
index 0000000..77af6c9
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/src/terminals.js
@@ -0,0 +1,261 @@
+/* Terminals: a generic shell that boots an (original-styled) AI agent session via `claude`. */
+
+const { ref, computed, onMounted, onUnmounted, watch, nextTick } = Vue;
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+let _lid = 0;
+const lid = () => ++_lid;
+
+function bootAgent() {
+ return [
+ { kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
+ { kind: "t", cls: "dim", html: " I can read, search and edit files in this project. Describe a change to get started." },
+ ];
+}
+
+function agentSeed() {
+ return {
+ mode: "agent",
+ lines: [
+ { id: lid(), kind: "t", html: `console % claude` },
+ { id: lid(), kind: "welcome", text: "model: opus · cwd: ~/console · branch: " + PROJECT.branch + " · type /exit to leave" },
+ { id: lid(), kind: "t", html: `> add currency + name to the user payload and gate the fillable fields` },
+ { id: lid(), kind: "t", html: `● Read UserController.php , edited the response array and onlyFillable() .` },
+ {
+ id: lid(), kind: "card", ch: "edit · src/Http/Controller/UserController.php",
+ rows: [
+ { cls: "del", t: "- 'plan' => $user->plan," },
+ { cls: "add", t: "+ 'plan' => $user->plan->value," },
+ { cls: "add", t: "+ 'name' => $user->name," },
+ ],
+ },
+ { id: lid(), kind: "t", cls: "ok", html: `● Done — updated UserController.php . Anything else?` },
+ ],
+ };
+}
+
+function shellSeed() {
+ return {
+ mode: "shell",
+ lines: [
+ { id: lid(), kind: "t", html: `console % git status -s` },
+ ...PROJECT.changes.map((c) => ({
+ id: lid(), kind: "t",
+ html: `${c.status} ${c.path} `,
+ })),
+ ],
+ };
+}
+
+const Terminal = {
+ props: ["kind", "seed"],
+ setup(props) {
+ const lines = ref(props.seed.lines);
+ const mode = ref(props.seed.mode);
+ const input = ref("");
+ const busy = ref(false);
+ const bodyRef = ref(null);
+ const inputRef = ref(null);
+ const hist = ref([]);
+ const histIx = ref(-1);
+
+ // Auto-scroll on lines/busy change
+ watch([lines, busy], () => nextTick(() => {
+ const el = bodyRef.value;
+ if (el) el.scrollTop = el.scrollHeight;
+ }));
+
+ const push = (line) => { lines.value = [...lines.value, { id: lid(), ...line }]; };
+ const pushMany = (arr) => { lines.value = [...lines.value, ...arr.map((l) => ({ id: lid(), ...l }))]; };
+
+ async function runAgent(prompt) {
+ busy.value = true;
+ push({ kind: "agent-think", html: `● thinking… ` });
+ await wait(520);
+ lines.value = lines.value.slice(0, -1); // drop the thinking line
+ push({ kind: "t", cls: "", html: `● I'll take a look at the relevant files first.` });
+ await wait(380);
+ push({ kind: "t", cls: "dim", html: ` read src/Http/Controller/UserController.php ` });
+ await wait(300);
+ push({ kind: "t", cls: "dim", html: ` grep "balanceFor" → 2 matches ` });
+ await wait(420);
+ push({ kind: "t", cls: "", html: `● Adding the field and a fillable allow-list. Editing now.` });
+ await wait(360);
+ push({
+ kind: "card", ch: `edit · src/Http/Controller/UserController.php`,
+ rows: [
+ { cls: "del", t: "- 'plan' => $user->plan," },
+ { cls: "add", t: "+ 'plan' => $user->plan->value," },
+ { cls: "add", t: "+ 'currency' => $user->currency," },
+ ],
+ });
+ await wait(450);
+ push({ kind: "t", cls: "ok", html: `● Done. Updated UserController.php . Run tests with composer test .` });
+ busy.value = false;
+ requestAnimationFrame(() => inputRef.value && inputRef.value.focus());
+ }
+
+ function shell(cmd) {
+ const [c, ...rest] = cmd.split(/\s+/);
+ const arg = rest.join(" ");
+ switch (c) {
+ case "": return;
+ case "claude":
+ pushMany(bootAgent());
+ mode.value = "agent";
+ return;
+ case "clear": lines.value = []; return;
+ case "help":
+ pushMany([
+ { kind: "t", cls: "dim", html: "commands: claude ls pwd cat <file> git status git diff echo clear" },
+ ]); return;
+ case "pwd": push({ kind: "t", html: "/Users/dev/console" }); return;
+ case "ls":
+ push({ kind: "t", html: "config public scripts src templates composer.json package.json README.md" });
+ return;
+ case "echo": push({ kind: "t", html: HL.escapeHtml(arg) }); return;
+ case "git":
+ if (rest[0] === "status") {
+ pushMany([
+ { kind: "t", cls: "dim", html: `On branch ${PROJECT.branch} ` },
+ { kind: "t", cls: "dim", html: "Changes to be committed:" },
+ ...PROJECT.changes.map((ch) => ({
+ kind: "t",
+ html: ` ${ch.status === 'A' ? 'new file' : ch.status === 'D' ? 'deleted ' : 'modified'} ${ch.path} `,
+ })),
+ ]);
+ } else if (rest[0] === "diff") {
+ pushMany([
+ { kind: "t", cls: "dim", html: "diff --git a/public/assets/app.js b/public/assets/app.js" },
+ { kind: "t", cls: "err", html: "- const res = await fetch('/api/session');" },
+ { kind: "t", cls: "ok", html: "+ const res = await fetch('/api/session', { credentials: 'include' });" },
+ ]);
+ } else push({ kind: "t", cls: "dim", html: `git: '${rest[0] || ""}' is not handled in this demo` });
+ return;
+ case "cat": {
+ const f = PROJECT.files[arg] || Object.entries(PROJECT.files).find(([p]) => p.endsWith(arg))?.[1];
+ if (!f) { push({ kind: "t", cls: "err", html: `cat: ${HL.escapeHtml(arg)}: No such file` }); return; }
+ pushMany(f.replace(/\n$/, "").split("\n").slice(0, 24).map((l) => ({ kind: "t", cls: "dim", html: HL.escapeHtml(l) || " " })));
+ return;
+ }
+ default: push({ kind: "t", cls: "err", html: `${HL.escapeHtml(c)}: command not found` });
+ }
+ }
+
+ function submit() {
+ const cmd = input.value.trim();
+ if (busy.value) return;
+ if (cmd) { hist.value.unshift(cmd); }
+ histIx.value = -1;
+ if (mode.value === "agent") {
+ if (cmd === "/exit" || cmd === "exit") {
+ push({ kind: "t", cls: "dim", html: "● Session ended." });
+ mode.value = "shell"; input.value = ""; return;
+ }
+ if (cmd === "/clear" || cmd === "clear") { lines.value = []; input.value = ""; return; }
+ push({ kind: "t", html: `> ${HL.escapeHtml(cmd).replace(/\n/g, " ") || " "}` });
+ input.value = "";
+ if (cmd) runAgent(cmd);
+ return;
+ }
+ push({ kind: "t", html: `console % ${HL.escapeHtml(cmd) || " "}` });
+ input.value = "";
+ shell(cmd);
+ }
+
+ function onKey(e) {
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); return; }
+ if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !input.value.includes("\n")) {
+ e.preventDefault();
+ if (e.key === "ArrowUp") {
+ if (hist.value.length) {
+ histIx.value = Math.min(histIx.value + 1, hist.value.length - 1);
+ input.value = hist.value[histIx.value];
+ }
+ } else {
+ if (histIx.value > 0) { histIx.value--; input.value = hist.value[histIx.value]; }
+ else { histIx.value = -1; input.value = ""; }
+ }
+ }
+ }
+
+ // agentPaste CustomEvent listener — agent kind only
+ let agentPasteHandler = null;
+ onMounted(() => {
+ if (props.kind !== "agent") return;
+ agentPasteHandler = (e) => {
+ if (mode.value !== "agent") { pushMany(bootAgent()); mode.value = "agent"; }
+ // bracketed-paste semantics: append as a new, UNSUBMITTED line; leave caret on a fresh line
+ const base = input.value.replace(/\n+$/, "");
+ input.value = (base ? base + "\n" : "") + e.detail + "\n";
+ requestAnimationFrame(() => {
+ const el = inputRef.value;
+ if (el) { el.focus(); const v = el.value.length; el.setSelectionRange(v, v); }
+ });
+ };
+ window.addEventListener("agentPaste", agentPasteHandler);
+ });
+
+ onUnmounted(() => {
+ if (agentPasteHandler) window.removeEventListener("agentPaste", agentPasteHandler);
+ });
+
+ const live = computed(() => mode.value === "agent");
+
+ const inputRows = computed(() => Math.min(8, Math.max(1, input.value.split("\n").length)));
+
+ const inputPlaceholder = computed(() =>
+ live.value ? "Ask the agent to change something…" : props.kind === "agent" ? "type 'claude' to start a session" : ""
+ );
+
+ return { lines, mode, input, busy, bodyRef, inputRef, live, inputRows, inputPlaceholder, onKey, submit };
+ },
+ template: `
+
+
+
+ {{ live ? 'claude' : kind === 'agent' ? 'claude' : 'zsh' }}
+ {{ live ? 'agent session' : '— bash · ~/console' }}
+
+
+
+
+
{{ l.ch }}
+
{{ r.t }}
+
+
+
◇ agent session
+
{{ l.text }}
+
+
+
+
● working
+
+
+ >
+ console %
+
+
+
+
+
+ `,
+};
+
+// Expose globals for app.js and other files
+window.wait = wait;
+window.lid = lid;
+window.bootAgent = bootAgent;
+window.agentSeed = agentSeed;
+window.shellSeed = shellSeed;
+
+window.HelderComponents = Object.assign(window.HelderComponents || {}, { Terminal });
diff --git a/design_handoff_helder_workbench/design-vue/styles.css b/design_handoff_helder_workbench/design-vue/styles.css
new file mode 100644
index 0000000..fba0ec5
--- /dev/null
+++ b/design_handoff_helder_workbench/design-vue/styles.css
@@ -0,0 +1,372 @@
+/* ============ Agentic Coding Panel — dark, charcoal-neutral ============ */
+:root {
+ --bg-0:#16171a; /* editor surface (deepest) */
+ --bg-1:#1a1c1f; /* terminals */
+ --bg-2:#1f2226; /* sidebars */
+ --bg-3:#23262b; /* headers / tabs strip */
+ --hover:#2a2e34;
+ --active:#313742;
+ --sel:#2b323d;
+ --border:#2a2d33;
+ --border-2:#34383f;
+ --fg-0:#e6e8ea;
+ --fg-1:#b4bac2;
+ --fg-2:#838a94;
+ --fg-3:#5d636c;
+ --accent:#4d8dff;
+ --accent-soft:rgba(77,141,255,0.16);
+ --accent-line:rgba(77,141,255,0.55);
+ --add:#5cbd6b;
+ --del:#e0696a;
+ --mod:#d8a85c;
+ --ren:#5aa6d6;
+ --add-bg:rgba(92,189,107,0.10);
+ --del-bg:rgba(224,105,106,0.10);
+ /* syntax */
+ --t-key:#c98bdb;
+ --t-str:#94c980;
+ --t-num:#e0a06a;
+ --t-fn:#6aa6f0;
+ --t-com:#5f656e;
+ --t-tag:#7fc6a0;
+ --t-attr:#d8b15c;
+ --t-punc:#9aa0a8;
+ --t-var:#e6e8ea;
+ --t-const:#e08b6a;
+ --t-prop:#6ec0c0;
+ --ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+ --mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
+}
+
+* { box-sizing:border-box; }
+html,body { margin:0; height:100%; }
+body {
+ background:var(--bg-0); color:var(--fg-0);
+ font-family:var(--ui); font-size:13px;
+ overflow:hidden; -webkit-font-smoothing:antialiased;
+}
+#root { height:100vh; }
+::selection { background:rgba(77,141,255,0.32); }
+
+/* scrollbars */
+::-webkit-scrollbar { width:11px; height:11px; }
+::-webkit-scrollbar-thumb { background:#393e46; border-radius:6px; border:3px solid transparent; background-clip:content-box; }
+::-webkit-scrollbar-thumb:hover { background:#4a505a; background-clip:content-box; }
+::-webkit-scrollbar-corner { background:transparent; }
+
+/* ============ shell ============ */
+.app { display:flex; flex-direction:column; height:100vh; }
+
+.titlebar {
+ height:36px; flex:0 0 36px; display:flex; align-items:center;
+ background:var(--bg-3); border-bottom:1px solid var(--border);
+ padding:0 12px; gap:14px; user-select:none;
+}
+.traffic { display:flex; gap:8px; }
+.traffic i { width:12px; height:12px; border-radius:50%; display:block; }
+.traffic .r{background:#e0696a;} .traffic .y{background:#d8a85c;} .traffic .g{background:#5cbd6b;}
+.tb-title { font-size:12px; color:var(--fg-1); display:flex; align-items:center; gap:7px; }
+.tb-title b { color:var(--fg-0); font-weight:600; }
+.tb-crumb { color:var(--fg-3); font-size:11.5px; font-family:var(--mono); }
+.tb-crumb .seg{color:var(--fg-2);}
+.tb-spacer { flex:1; }
+.tb-actions { display:flex; gap:6px; align-items:center; }
+.tb-btn {
+ font-size:11.5px; color:var(--fg-2); background:transparent; border:1px solid transparent;
+ border-radius:6px; padding:4px 9px; cursor:pointer; display:flex; align-items:center; gap:6px;
+}
+.tb-btn:hover { background:var(--hover); color:var(--fg-0); }
+.tb-btn kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
+
+.workbench { flex:1; display:flex; min-height:0; }
+
+.col { display:flex; flex-direction:column; height:100%; min-width:0; background:var(--bg-2); }
+.col.editor-col { flex:1; background:var(--bg-0); min-width:240px; }
+.col.right-col { background:var(--bg-1); }
+
+.splitter { flex:0 0 5px; cursor:col-resize; background:transparent; position:relative; z-index:5; }
+.splitter::after { content:""; position:absolute; inset:0 2px; background:var(--border); transition:background .12s; }
+.splitter:hover::after, .splitter.drag::after { background:var(--accent-line); }
+.splitter.h { cursor:row-resize; flex:0 0 5px; width:100%; }
+
+/* panel header */
+.phead {
+ height:30px; flex:0 0 30px; display:flex; align-items:center; gap:8px;
+ padding:0 10px 0 12px; font-size:10.5px; letter-spacing:.09em; text-transform:uppercase;
+ color:var(--fg-2); border-bottom:1px solid var(--border); user-select:none;
+}
+.phead .ct { margin-left:auto; font-size:10px; color:var(--fg-3); letter-spacing:.02em; text-transform:none;
+ background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:16px; }
+.phead .ico-btn { color:var(--fg-3); cursor:pointer; padding:2px; border-radius:4px; display:flex; }
+.phead .ico-btn:hover { background:var(--hover); color:var(--fg-1); }
+
+/* ============ git panel ============ */
+.commit-box { padding:9px 10px; border-bottom:1px solid var(--border); display:flex; gap:7px; align-items:flex-start; }
+.commit-input { flex:1; min-width:0; resize:none; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:12px; line-height:16px; padding:7px 9px; outline:none; min-height:32px; }
+.commit-input:focus { border-color:var(--accent-line); }
+.commit-input::placeholder { color:var(--fg-3); }
+.commit-btn { flex:0 0 auto; display:flex; align-items:center; gap:6px; background:var(--accent); color:#0c1320; border:0; border-radius:7px; font:inherit; font-size:12px; font-weight:600; padding:0 11px; height:32px; cursor:pointer; }
+.commit-btn:hover:not(:disabled) { background:#5d97ff; }
+.commit-btn:disabled { background:var(--bg-3); color:var(--fg-3); cursor:not-allowed; }
+.git-body { overflow:auto; flex:1; padding:4px 0 10px; }
+.git-group { padding:8px 12px 3px; font-size:10px; letter-spacing:.06em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:6px; }
+.git-group .gc { color:var(--fg-3); }
+.git-group .grp-act { margin-left:auto; display:flex; opacity:0; background:transparent; border:0; color:var(--fg-2); padding:2px; border-radius:4px; cursor:pointer; }
+.git-group:hover .grp-act { opacity:1; }
+.git-group .grp-act:hover { background:var(--hover); color:var(--fg-0); }
+.git-empty { display:flex; flex-direction:column; align-items:center; gap:9px; padding:30px 16px; color:var(--fg-3); font-size:12px; text-align:center; }
+.git-empty svg { color:var(--add); opacity:.7; }
+.git-none { padding:5px 14px 9px; font-size:11.5px; color:var(--fg-3); }
+.git-none .key { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
+.git-divider { height:1px; background:var(--border); margin:8px 12px 2px; }
+.git-row {
+ display:flex; align-items:center; gap:8px; padding:3px 12px 3px 14px; cursor:pointer; position:relative;
+}
+.git-row:hover { background:var(--hover); }
+.git-row.active { background:var(--sel); }
+.git-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
+.git-stat { width:13px; text-align:center; font-family:var(--mono); font-size:11px; font-weight:600; flex:0 0 13px; }
+.git-stat.M{color:var(--mod);} .git-stat.A{color:var(--add);} .git-stat.D{color:var(--del);} .git-stat.R{color:var(--ren);} .git-stat.U{color:var(--add);}
+.git-name { font-size:12.5px; color:var(--fg-1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+.git-row.active .git-name { color:var(--fg-0); }
+.git-name.del { text-decoration:line-through; color:var(--fg-3); }
+.git-dir { color:var(--fg-3); font-size:11px; margin-left:auto; padding-left:8px; white-space:nowrap; max-width:42%; overflow:hidden; text-overflow:ellipsis; direction:rtl; }
+.git-act { flex:0 0 auto; display:none; align-items:center; justify-content:center; width:20px; height:20px; padding:0; background:transparent; border:0; border-radius:5px; color:var(--fg-2); cursor:pointer; margin-left:4px; }
+.git-row:hover .git-act { display:flex; }
+.git-act:hover { background:var(--active); color:var(--fg-0); }
+.git-delta { font-family:var(--mono); font-size:10.5px; display:flex; gap:6px; flex:0 0 auto; }
+.git-delta .a{color:var(--add);} .git-delta .d{color:var(--del);}
+.git-foot { border-top:1px solid var(--border); padding:8px 12px; display:flex; align-items:center; gap:8px; font-size:11px; color:var(--fg-2); }
+.branch-chip { display:flex; align-items:center; gap:6px; color:var(--fg-1); }
+.branch-chip b { font-weight:600; color:var(--fg-0); }
+
+/* ============ file tree ============ */
+.tree-body { overflow:auto; flex:1; padding:4px 0 14px; }
+.tree-row { display:flex; align-items:center; gap:6px; padding:2px 10px 2px 0; cursor:pointer; white-space:nowrap; position:relative; height:23px; }
+.tree-row:hover { background:var(--hover); }
+.tree-row.active { background:var(--sel); }
+.tree-row.active::before { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--accent); }
+.tw { display:inline-flex; align-items:center; justify-content:center; width:14px; flex:0 0 14px; color:var(--fg-3); font-size:10px; }
+.tree-label { font-size:12.5px; color:var(--fg-1); overflow:hidden; text-overflow:ellipsis; }
+.tree-row.active .tree-label { color:var(--fg-0); }
+.tree-row.folder .tree-label { color:var(--fg-1); }
+.tree-badge { margin-left:auto; font-family:var(--mono); font-size:10px; font-weight:600; padding-left:8px; }
+.tree-badge.M{color:var(--mod);} .tree-badge.A{color:var(--add);} .tree-badge.D{color:var(--del);}
+
+/* file type monogram icon */
+.ficon { width:15px; height:15px; flex:0 0 15px; border-radius:3.5px; display:inline-flex; align-items:center; justify-content:center;
+ font-family:var(--mono); font-size:7.5px; font-weight:700; color:#11131600; position:relative; }
+.ficon span { color:#0c0d0f; font-size:7.5px; line-height:1; letter-spacing:-.3px; }
+.folder-ic { width:15px; height:15px; flex:0 0 15px; display:inline-flex; align-items:center; justify-content:center; color:var(--fg-2); }
+
+/* ============ editor ============ */
+.tabs { height:35px; flex:0 0 35px; display:flex; align-items:stretch; background:var(--bg-3); border-bottom:1px solid var(--border); overflow-x:auto; overflow-y:hidden; }
+.tabs::-webkit-scrollbar { height:0; }
+.tab {
+ display:flex; align-items:center; gap:7px; padding:0 9px 0 13px; cursor:pointer;
+ border-right:1px solid var(--border); color:var(--fg-2); font-size:12.5px; white-space:nowrap;
+ background:var(--bg-3); position:relative; max-width:230px;
+}
+.tab:hover { background:#272b31; }
+.tab.active { background:var(--bg-0); color:var(--fg-0); }
+.tab.active::after { content:""; position:absolute; left:0; right:0; top:0; height:2px; background:var(--accent); }
+.tab .tname { overflow:hidden; text-overflow:ellipsis; }
+.tab.dirty .tname::after { content:" ●"; color:var(--mod); font-size:10px; }
+.tab .tclose { width:17px; height:17px; border-radius:4px; display:flex; align-items:center; justify-content:center; color:var(--fg-3); flex:0 0 17px; }
+.tab .tclose:hover { background:var(--active); color:var(--fg-0); }
+.tab .tdot { display:none; width:7px; height:7px; border-radius:50%; background:var(--fg-2); }
+.tab.dirtyclose .tclose { display:none; }
+.tab.dirtyclose:hover .tclose { display:flex; }
+.tab.dirtyclose:hover .tdot { display:none; }
+.tab.dirtyclose .tdot { display:block; }
+.tab-mode { margin-left:6px; font-size:9.5px; letter-spacing:.05em; text-transform:uppercase; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; line-height:14px; }
+
+.editor-wrap { flex:1; min-height:0; display:flex; flex-direction:column; position:relative; }
+.editor { flex:1; overflow:auto; font-family:var(--mono); font-size:13px; line-height:20px; padding:6px 0 40px; }
+.ln-row { display:flex; align-items:flex-start; min-height:20px; }
+.ln-row.cursor { background:rgba(255,255,255,0.035); }
+.ln-row.add { background:var(--add-bg); }
+.ln-row.del { background:var(--del-bg); }
+.ln-row.selrange { background:var(--accent-soft); }
+.ln-gutter { flex:0 0 54px; width:54px; text-align:right; padding-right:14px; color:var(--fg-3); user-select:none; cursor:pointer; font-size:12px; }
+.ln-row.cursor .ln-gutter { color:var(--fg-1); }
+.ln-gutter:hover { color:var(--fg-1); }
+.ln-sign { flex:0 0 14px; width:14px; text-align:center; user-select:none; color:var(--fg-3); }
+.ln-row.add .ln-sign { color:var(--add); }
+.ln-row.del .ln-sign { color:var(--del); }
+.ln-code { flex:1; white-space:pre; padding:0 16px 0 6px; min-width:0; }
+.editor.diff .ln-code { padding-left:6px; }
+.empty-ed { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--fg-3); gap:14px; }
+.empty-ed .big { font-size:13px; }
+.empty-ed kbd { font-family:var(--mono); font-size:11px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; }
+.empty-ed .klist { display:flex; flex-direction:column; gap:9px; font-size:12px; }
+.empty-ed .klist div { display:flex; gap:10px; align-items:center; justify-content:space-between; min-width:230px; }
+
+.diff-bar { height:26px; flex:0 0 26px; display:flex; align-items:center; gap:12px; padding:0 14px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); }
+.diff-bar .a{color:var(--add);font-family:var(--mono);} .diff-bar .d{color:var(--del);font-family:var(--mono);}
+.diff-bar .toggle { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:6px; overflow:hidden; }
+.diff-bar .toggle button { background:transparent; border:0; color:var(--fg-2); font:inherit; font-size:11px; padding:2px 10px; cursor:pointer; }
+.diff-bar .toggle button.on { background:var(--accent-soft); color:var(--fg-0); }
+
+/* four-segment view control */
+.diff-bar .seg { margin-left:auto; display:flex; border:1px solid var(--border-2); border-radius:7px; overflow:hidden; }
+.diff-bar .seg button { background:transparent; border:0; border-right:1px solid var(--border-2); color:var(--fg-2); font:inherit; font-size:11px; padding:3px 12px; cursor:pointer; display:flex; align-items:center; gap:6px; }
+.diff-bar .seg button:last-child { border-right:0; }
+.diff-bar .seg button:hover { color:var(--fg-0); background:var(--hover); }
+.diff-bar .seg button.on { background:var(--accent-soft); color:var(--fg-0); }
+.diff-bar .seg .split-btn svg { opacity:.85; }
+
+/* gutter change bars (Original / Updated / Split) */
+.ln-row.bar-del { box-shadow:inset 2px 0 0 var(--del); }
+.ln-row.bar-add { box-shadow:inset 2px 0 0 var(--add); }
+.ln-row.empty { background:repeating-linear-gradient(45deg, rgba(255,255,255,0.015) 0 7px, transparent 7px 14px); }
+
+/* full-screen split */
+.split-overlay { position:fixed; inset:0; z-index:60; background:var(--bg-0); display:flex; flex-direction:column; animation:tin .12s ease-out; }
+.split-head { height:42px; flex:0 0 42px; display:flex; align-items:center; gap:11px; padding:0 16px; background:var(--bg-3); border-bottom:1px solid var(--border); }
+.split-head .sh-name { font-family:var(--mono); font-size:13px; color:var(--fg-0); }
+.split-head .git-stat { font-size:11px; }
+.split-exit { margin-left:auto; display:flex; align-items:center; gap:7px; background:transparent; border:1px solid var(--border-2); border-radius:7px; color:var(--fg-1); font:inherit; font-size:12px; padding:5px 11px; cursor:pointer; }
+.split-exit:hover { background:var(--hover); color:var(--fg-0); }
+.split-exit kbd { font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 5px; }
+.split-body { flex:1; display:flex; min-height:0; }
+.split-pane { flex:1; min-width:0; display:flex; flex-direction:column; }
+.split-pane.left { border-right:1px solid var(--border-2); }
+.split-label { height:27px; flex:0 0 27px; display:flex; align-items:center; gap:9px; padding:0 16px; font-size:10.5px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-2); background:var(--bg-2); border-bottom:1px solid var(--border); }
+.split-label span { text-transform:none; letter-spacing:0; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
+
+/* syntax token colors */
+.ln-code .token.keyword,.ln-code .token.rule,.ln-code .token.atrule,.ln-code .token.important{color:var(--t-key);}
+.ln-code .token.string,.ln-code .token.attr-value,.ln-code .token.char,.ln-code .token.regex{color:var(--t-str);}
+.ln-code .token.number,.ln-code .token.unit{color:var(--t-num);}
+.ln-code .token.function,.ln-code .token.method{color:var(--t-fn);}
+.ln-code .token.comment,.ln-code .token.prolog,.ln-code .token.doctype,.ln-code .token.cdata{color:var(--t-com);font-style:italic;}
+.ln-code .token.tag{color:var(--t-tag);}
+.ln-code .token.attr-name{color:var(--t-attr);}
+.ln-code .token.punctuation{color:var(--t-punc);}
+.ln-code .token.operator{color:var(--t-punc);}
+.ln-code .token.variable,.ln-code .token.symbol{color:var(--t-var);}
+.ln-code .token.constant,.ln-code .token.boolean,.ln-code .token.builtin{color:var(--t-const);}
+.ln-code .token.property,.ln-code .token.property-access{color:var(--t-prop);}
+.ln-code .token.class-name,.ln-code .token.maybe-class-name{color:var(--t-attr);}
+.ln-code .token.parameter{color:var(--fg-0);}
+.ln-code .token.namespace{color:var(--fg-2);}
+.ln-code .token.selector{color:var(--t-tag);}
+.ln-code .token.entity,.ln-code .token.url{color:var(--t-prop);}
+.ln-code .token.deleted{color:var(--del);} .ln-code .token.inserted{color:var(--add);}
+
+/* ============ terminals (right column) ============ */
+.term-pane { display:flex; flex-direction:column; min-height:0; background:var(--bg-1); }
+.term-head { height:28px; flex:0 0 28px; display:flex; align-items:center; gap:8px; padding:0 10px; background:var(--bg-3); border-bottom:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
+.term-head .dot { width:7px; height:7px; border-radius:50%; background:var(--fg-3); }
+.term-head .dot.live { background:var(--add); box-shadow:0 0 0 0 rgba(92,189,107,.5); animation:pulse 2.2s infinite; }
+@keyframes pulse { 0%{box-shadow:0 0 0 0 rgba(92,189,107,.45);} 70%{box-shadow:0 0 0 5px rgba(92,189,107,0);} 100%{box-shadow:0 0 0 0 rgba(92,189,107,0);} }
+.term-head .lbl { color:var(--fg-1); font-family:var(--mono); }
+.term-head .tag { margin-left:auto; font-size:10px; color:var(--fg-3); font-family:var(--mono); }
+.term-body { flex:1; overflow:auto; padding:8px 12px 12px; font-family:var(--mono); font-size:12.5px; line-height:18px; cursor:text; }
+.tline { white-space:pre-wrap; word-break:break-word; }
+.tline.dim{color:var(--fg-3);} .tline.acc{color:var(--accent);} .tline.ok{color:var(--add);} .tline.warn{color:var(--mod);} .tline.err{color:var(--del);}
+.tline .pfx { color:var(--accent); }
+.tline .ag { color:#c98bdb; }
+.tline .tool { color:var(--mod); }
+.tline .fp { color:var(--t-prop); }
+.term-card { border:1px solid var(--border-2); border-radius:7px; padding:7px 10px; margin:5px 0; background:rgba(255,255,255,0.02); }
+.term-card .ch { color:var(--fg-2); font-size:11px; margin-bottom:4px; display:flex; gap:7px; align-items:center; min-width:0; }
+.term-card .ch span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.term-card .add,.term-card .del { white-space:pre-wrap; word-break:break-word; line-height:17px; }
+.term-card .add{color:var(--add);} .term-card .del{color:var(--del);}
+.term-input { display:flex; align-items:center; gap:8px; }
+.term-input .ip { color:var(--accent); }
+.term-input .ip.ag { color:#c98bdb; }
+.term-input input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; caret-color:var(--accent); }
+.cursor-blink { display:inline-block; width:7px; height:14px; background:var(--accent); margin-left:1px; animation:blink 1.1s step-end infinite; vertical-align:-2px; }
+@keyframes blink { 50%{opacity:0;} }
+
+/* ============ overlays ============ */
+.scrim { position:fixed; inset:0; background:rgba(8,9,11,0.5); z-index:50; display:flex; justify-content:center; align-items:flex-start; padding-top:90px; backdrop-filter:blur(1.5px); }
+.palette { width:620px; max-width:92vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; }
+.palette .pi { display:flex; align-items:center; gap:10px; padding:12px 15px; border-bottom:1px solid var(--border); }
+.palette .pi input { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-size:15px; font-family:var(--ui); }
+.palette .pi .mode-chip { font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:5px; padding:2px 7px; font-family:var(--mono); }
+.palette .results { max-height:380px; overflow:auto; padding:6px; }
+.pres { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px; cursor:pointer; }
+.pres.sel { background:var(--accent-soft); }
+.pres .pn { font-size:13px; color:var(--fg-0); }
+.pres .pn b { color:var(--accent); font-weight:700; }
+.pres .pp { font-size:11px; color:var(--fg-3); margin-left:auto; font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:55%; direction:rtl; }
+.pempty { padding:26px; text-align:center; color:var(--fg-3); font-size:12.5px; }
+
+/* combined search modal (content + files) */
+.search-modal { width:940px; max-width:94vw; background:#212429; border:1px solid var(--border-2); border-radius:11px; box-shadow:0 24px 70px rgba(0,0,0,.55); overflow:hidden; display:flex; flex-direction:column; }
+.search-cols { display:flex; min-height:0; }
+.sc-left { flex:1 1 auto; min-width:0; max-height:460px; overflow:auto; border-right:1px solid var(--border); padding-bottom:8px; }
+.sc-right { flex:0 0 256px; min-width:0; max-height:460px; overflow:auto; padding-bottom:8px; background:rgba(0,0,0,0.12); }
+.sc-head { position:sticky; top:0; z-index:1; background:#212429; padding:9px 14px 6px; font-size:10px; letter-spacing:.07em; text-transform:uppercase; color:var(--fg-3); display:flex; align-items:center; gap:7px; }
+.sc-right .sc-head { background:#1e2024; }
+.sc-head .sc-ct { color:var(--fg-2); background:var(--bg-3); border:1px solid var(--border); border-radius:9px; padding:0 7px; line-height:15px; font-size:10px; }
+.srf-name { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.pempty.sm { padding:18px 14px; text-align:left; font-size:11.5px; }
+.fres { display:flex; align-items:center; gap:9px; padding:6px 12px; cursor:pointer; }
+.fres:hover { background:var(--hover); }
+.fres-txt { min-width:0; display:flex; flex-direction:column; line-height:1.25; }
+.fres-txt .fn { font-size:12.5px; color:var(--fg-0); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.fres-txt .fn b { color:var(--accent); font-weight:700; }
+.fres-txt .fd { font-size:10.5px; color:var(--fg-3); font-family:var(--mono); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+
+/* content search */
+.search-results { max-height:420px; overflow:auto; padding:4px 0 8px; }
+.sr-file { padding:7px 14px 3px; font-size:11.5px; color:var(--fg-2); display:flex; align-items:center; gap:8px; cursor:pointer; }
+.sr-file:hover { color:var(--fg-0); }
+.sr-file .cnt { margin-left:auto; color:var(--fg-3); font-family:var(--mono); font-size:10.5px; }
+.sr-line { display:flex; gap:12px; padding:2px 14px 2px 38px; font-family:var(--mono); font-size:12px; cursor:pointer; color:var(--fg-1); }
+.sr-line:hover { background:var(--hover); }
+.sr-line .no { color:var(--fg-3); min-width:34px; text-align:right; }
+.sr-line .tx { white-space:pre; overflow:hidden; text-overflow:ellipsis; }
+.sr-line mark { background:rgba(216,168,92,.28); color:var(--fg-0); border-radius:2px; }
+
+/* pass-on-to-agent inline popup */
+.pass-pop { position:fixed; z-index:85; width:344px; max-width:92vw; background:#23272d; border:1px solid var(--border-2); border-radius:10px; box-shadow:0 18px 48px rgba(0,0,0,.55); padding:11px; animation:popin .12s ease-out; }
+@keyframes popin { from { transform:translateY(6px); } }
+.pass-head { display:flex; align-items:center; gap:8px; font-size:12px; color:var(--fg-1); margin-bottom:9px; }
+.pass-head svg { color:#c98bdb; }
+.pass-head .pass-esc { margin-left:auto; font-family:var(--mono); font-size:10px; color:var(--fg-3); border:1px solid var(--border-2); border-radius:4px; padding:1px 6px; }
+.pass-input { width:100%; box-sizing:border-box; background:var(--bg-0); border:1px solid var(--border-2); border-radius:7px; color:var(--fg-0); font-family:var(--ui); font-size:13px; padding:8px 10px; outline:none; }
+.pass-input:focus { border-color:var(--accent-line); }
+.pass-input::placeholder { color:var(--fg-3); }
+.pass-preview { margin-top:9px; display:flex; align-items:center; gap:8px; min-width:0; }
+.pass-preview .pp-lbl { font-size:10px; text-transform:uppercase; letter-spacing:.06em; color:var(--fg-3); flex:0 0 auto; }
+.pass-preview code { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:3px 7px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+.pass-foot { margin-top:9px; font-size:10.5px; color:var(--fg-3); }
+.pass-foot kbd { font-family:var(--mono); font-size:10px; color:var(--fg-2); border:1px solid var(--border-2); border-radius:4px; padding:0 5px; }
+
+/* terminal multi-line input */
+.term-input { align-items:flex-start; }
+.term-ta { flex:1; background:transparent; border:0; outline:0; color:var(--fg-0); font-family:var(--mono); font-size:12.5px; line-height:18px; caret-color:var(--accent); resize:none; padding:0; margin:0; overflow:hidden; }
+.term-ta::placeholder { color:var(--fg-3); }
+
+/* context menu */.ctx { position:fixed; z-index:80; background:#23272d; border:1px solid var(--border-2); border-radius:9px; padding:5px; min-width:248px; box-shadow:0 16px 44px rgba(0,0,0,.5); }
+.ctx-item { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:6px; cursor:pointer; font-size:12.5px; color:var(--fg-1); }
+.ctx-item:hover { background:var(--accent-soft); color:var(--fg-0); }
+.ctx-item .kc { margin-left:auto; font-family:var(--mono); font-size:10.5px; color:var(--fg-3); }
+.ctx-item.primary { color:var(--fg-0); }
+.ctx-item.primary .ic { color:var(--accent); }
+.ctx-item .ic { width:15px; display:flex; justify-content:center; color:var(--fg-3); }
+.ctx-sep { height:1px; background:var(--border); margin:5px 6px; }
+.ctx-note { padding:4px 11px 7px; font-size:10.5px; color:var(--fg-3); font-family:var(--mono); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+
+/* toast */
+.toast-wrap { position:fixed; bottom:34px; left:50%; transform:translateX(-50%); z-index:90; display:flex; flex-direction:column; gap:8px; align-items:center; }
+.toast { background:#23272d; border:1px solid var(--border-2); border-left:3px solid var(--accent); border-radius:9px; padding:9px 14px; box-shadow:0 12px 34px rgba(0,0,0,.45); display:flex; align-items:center; gap:11px; animation:tin .18s ease-out; }
+@keyframes tin { from{opacity:0; transform:translateY(8px);} }
+.toast .tt { font-size:12.5px; color:var(--fg-0); }
+.toast .tref { font-family:var(--mono); font-size:11.5px; color:var(--accent); background:var(--accent-soft); border-radius:5px; padding:2px 8px; }
+
+/* ============ status bar ============ */
+.statusbar { height:23px; flex:0 0 23px; display:flex; align-items:center; gap:0; background:var(--bg-3); border-top:1px solid var(--border); font-size:11px; color:var(--fg-2); user-select:none; }
+.sb { display:flex; align-items:center; gap:6px; padding:0 11px; height:100%; }
+.sb:hover { background:var(--hover); }
+.sb.accent { background:var(--accent); color:#0c1320; }
+.sb.accent:hover { background:#5d97ff; }
+.sb.spacer { flex:1; }
+.sb .a{color:var(--add);} .sb .d{color:var(--del);}
+.sb b { font-weight:600; color:var(--fg-1); }