From 3d77fdfeff141944cedb6a21d5e736c74665310e Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Mon, 15 Jun 2026 10:03:57 +0200 Subject: [PATCH] adds handoff --- .../console-2026-06-15T08-02-31-469Z.log | 7 + .../page-2026-06-15T08-02-31-796Z.yml | 0 .../design-vue/index.html | 40 + .../design-vue/src/app.js | 464 ++++++++++++ .../design-vue/src/components.js | 395 ++++++++++ .../design-vue/src/data.js | 712 ++++++++++++++++++ .../design-vue/src/editor.js | 331 ++++++++ .../design-vue/src/highlight.js | 79 ++ .../design-vue/src/overlays.js | 332 ++++++++ .../design-vue/src/terminals.js | 261 +++++++ .../design-vue/styles.css | 372 +++++++++ 11 files changed, 2993 insertions(+) create mode 100644 .playwright-mcp/console-2026-06-15T08-02-31-469Z.log create mode 100644 .playwright-mcp/page-2026-06-15T08-02-31-796Z.yml create mode 100644 design_handoff_helder_workbench/design-vue/index.html create mode 100644 design_handoff_helder_workbench/design-vue/src/app.js create mode 100644 design_handoff_helder_workbench/design-vue/src/components.js create mode 100644 design_handoff_helder_workbench/design-vue/src/data.js create mode 100644 design_handoff_helder_workbench/design-vue/src/editor.js create mode 100644 design_handoff_helder_workbench/design-vue/src/highlight.js create mode 100644 design_handoff_helder_workbench/design-vue/src/overlays.js create mode 100644 design_handoff_helder_workbench/design-vue/src/terminals.js create mode 100644 design_handoff_helder_workbench/design-vue/styles.css 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 }} +
+
+ +
+
+
+ +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ + +
+
+ + {{ PROJECT.branch }} +
+
+ +{{ totals.add }} + −{{ totals.del }} +
+
+
+ + +
+
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: ` +
+ {{ 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 }} +
+ +
+ + +
+ +
+
+ No changes — working tree clean +
+ +
+ +
+ {{ PROJECT.branch }} + + +{{ totals.add }}{{ ' ' }} + -{{ totals.del }} + +
+ `, +}; + +/* ---- 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: ` + +
+ + + + {{ 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 + + + +
+
+

Console

+ +
+
+
+
+ + + +`; + + 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: ` +
+
+ + {{ 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: ` +
+ +
+ `, +}; + +/* ---------- 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 referenceright-click
+
Pass on to Agentright-click
+
+
+ +
+
+ {{ statusWord }} + +{{ change.add }} + −{{ change.del }} +
+ + +
+
+ +
+
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 }} + +
+
+
+
Original before
+
+
+ {{ row.l ? row.l.no : '' }} + +
+
+
+
+
Updated after
+
+
+ {{ 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: ` + + + + ` +}; + +// --------------------------------------------------------------------------- +// 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
+ +
+
+
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 }}
+ +
+ ` +}; + +// --------------------------------------------------------------------------- +// 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 Agentesc
+ +
inserts{{ preview }}
+
insert into agent · esc cancel
+
+ ` +}; + +// --------------------------------------------------------------------------- +// 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' }} +
+
+ +
working
+
+ + + + + +
+
+
+ `, +}; + +// 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); }