feat(*): svelte 5 panel view sharing the gateway ui, obsidian theme, preview and tests

This commit is contained in:
hh
2026-08-29 18:30:31 +02:00
parent a1bf4db905
commit 04400c5076
33 changed files with 3378 additions and 1803 deletions
+81
View File
@@ -0,0 +1,81 @@
export class TFile {
path: string;
extension: string;
constructor(path: string, extension = "md") {
this.path = path;
this.extension = extension;
}
}
export class TFolder {
path: string;
constructor(path: string) {
this.path = path;
}
}
export const notices: string[] = [];
export class Notice {
constructor(message: string) {
notices.push(message);
}
hide(): void {}
}
export class Plugin {}
export class ItemView {}
export class PluginSettingTab {}
export class Setting {}
export class Component {}
export class FuzzySuggestModal {}
export const Platform = { isMobile: false };
export const moment = () => undefined;
export const requestUrl = () =>
Promise.reject(new Error("no network in the stub"));
export const setIcon = () => undefined;
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// Enough of Obsidian's renderer for the panel: paragraphs, bold, code,
// wikilinks as ``a.internal-link`` with ``data-href``, bare URLs.
export const MarkdownRenderer = {
render(
_app: unknown,
markdown: string,
el: HTMLElement,
_sourcePath: string,
_component: unknown
): Promise<void> {
const blocks = markdown.split(/\n{2,}/).map((block) => {
const html = escapeHtml(block)
.replace(
/!?\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_full, target: string, alias?: string) =>
`<a class="internal-link" data-href="${target}" href="${target}">${alias ?? target}</a>`
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(
/(^|\s)(https?:\/\/[^\s]+)/g,
(_full, lead: string, url: string) =>
`${lead}<a class="external-link" href="${url}">${url}</a>`
)
.replace(/\n/g, "<br>");
if (block.startsWith("- ")) {
const items = html
.split("<br>")
.map((line) => `<li>${line.replace(/^- /, "")}</li>`);
return `<ul>${items.join("")}</ul>`;
}
return `<p>${html}</p>`;
});
el.innerHTML = blocks.join("");
return Promise.resolve();
},
};
+305
View File
@@ -0,0 +1,305 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import esbuild from "esbuild";
import { JSDOM } from "jsdom";
import { root, sharedLib, svelte } from "../esbuild.config.mjs";
const out = join(mkdtempSync(join(tmpdir(), "beaver-")), "bundle.mjs");
writeFileSync(
join(root, "tests/.entry.ts"),
`export { default as App } from "../src/ui/app.svelte";
export { PanelState } from "../src/state.svelte";
export { FakeClient, MASTER, BRANCH_UFW, BRANCH_BLASTER, DEEP_PANEL } from "../preview/fake-client";
export { mount, unmount, flushSync } from "svelte";
export { Component } from "obsidian";
export { conversationActions } from "$lib/panel/actions.svelte";
`
);
await esbuild.build({
bundle: true,
conditions: ["svelte", "browser"],
entryPoints: [join(root, "tests/.entry.ts")],
format: "esm",
logLevel: "error",
mainFields: ["svelte", "browser", "module", "main"],
outfile: out,
plugins: [
sharedLib({ obsidian: join(root, "tests/obsidian-stub.ts") }),
svelte(),
],
target: "es2022",
});
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
for (const key of Object.getOwnPropertyNames(dom.window)) {
if (key.startsWith("_") || globalThis[key] !== undefined) {
continue;
}
try {
globalThis[key] = dom.window[key];
} catch {
/* getter-only window properties are fine to skip */
}
}
globalThis.window = dom.window;
globalThis.document = dom.window.document;
globalThis.Event = dom.window.Event;
globalThis.CustomEvent = dom.window.CustomEvent;
dom.window.Element.prototype.getBoundingClientRect = () => ({
bottom: 0,
height: 0,
left: 0,
right: 0,
toJSON: () => ({}),
top: 0,
width: 0,
x: 0,
y: 0,
});
dom.window.Element.prototype.setPointerCapture = () => {};
dom.window.Element.prototype.releasePointerCapture = () => {};
dom.window.Element.prototype.hasPointerCapture = () => false;
dom.window.Element.prototype.scrollIntoView = () => {};
dom.window.Element.prototype.animate = () => {
const animation = {
addEventListener() {},
cancel() {},
commitStyles() {},
currentTime: 0,
effect: { getComputedTiming: () => ({ delay: 0, duration: 0 }) },
finish() {},
finished: Promise.resolve(),
onfinish: null,
pause() {},
play() {},
playState: "finished",
removeEventListener() {},
reverse() {},
startTime: 0,
};
queueMicrotask(() => animation.onfinish?.());
return animation;
};
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
};
globalThis.matchMedia = () => ({
addEventListener() {},
matches: false,
removeEventListener() {},
});
dom.window.matchMedia = globalThis.matchMedia;
const {
App,
PanelState,
FakeClient,
MASTER,
BRANCH_UFW,
BRANCH_BLASTER,
DEEP_PANEL,
mount,
unmount,
flushSync,
Component,
conversationActions,
} = await import(pathToFileURL(out).href);
const failures = [];
function check(name, condition, detail = "") {
if (condition) {
console.log(` ok ${name}`);
} else {
failures.push(name);
console.log(` FAIL ${name}${detail ? ` -- ${detail}` : ""}`);
}
}
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
const client = new FakeClient();
const state = new PanelState();
state.selected = MASTER;
const opened = [];
const app = {
workspace: {
openLinkText: (link, from) => opened.push({ from, target: link }),
},
};
const target = dom.window.document.body;
const instance = mount(App, {
props: {
app,
client,
component: new Component(),
onOpenSettings: () => {},
state,
vaultSubpath: () => "💬 чаты",
},
target,
});
flushSync();
await settle();
flushSync();
const text = () => target.textContent ?? "";
check(
"root carries the theme class",
target.querySelector(".beaver-root") !== null
);
check(
"portal layer is on body",
target.querySelector(".beaver-portal") !== null
);
check("thread header shows the master", text().includes("Master · 29 Aug"));
check(
"the running turn's tools are on screen",
text().includes("Task") && text().includes("Bash")
);
check(
"history rendered through the host renderer",
target.querySelector(".beaver-md") !== null
);
const wikilink = target.querySelector("a.internal-link");
check("wikilink rendered as an internal link", wikilink !== null);
if (wikilink) {
wikilink.dispatchEvent(
new dom.window.MouseEvent("click", { bubbles: true, cancelable: true })
);
check(
"clicking it opens the note in Obsidian",
opened.some((o) => o.target === "2026-08-28"),
JSON.stringify(opened)
);
}
state.selected = BRANCH_BLASTER;
flushSync();
await settle();
flushSync();
check("switching follows the state", text().includes("бластер"));
check(
"a pending question shows its buttons",
text().includes("Xiaomi Mi Blaster")
);
const composer = target.querySelector("textarea");
check("composer is there", composer !== null);
if (composer) {
composer.value = "закажи Broadlink";
composer.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
flushSync();
composer
.closest("form")
?.dispatchEvent(
new dom.window.Event("submit", { bubbles: true, cancelable: true })
);
await settle();
check(
"sending goes out as source=panel",
client.sent.some(
(m) => m.origin === "panel" && m.text === "закажи Broadlink"
),
JSON.stringify(client.sent)
);
}
const switcher = target.querySelector('[aria-label="Switch conversation"]');
check("narrow layout shows the switcher", switcher !== null);
switcher?.dispatchEvent(
new dom.window.PointerEvent("pointerdown", {
bubbles: true,
button: 0,
pointerType: "mouse",
})
);
switcher?.click();
flushSync();
await settle();
const rows = [...dom.window.document.querySelectorAll("[data-row]")];
check(
"switcher lists open conversations grouped",
rows.length >= 4 &&
text().includes("Branches") &&
text().includes("Deep chats"),
`${rows.length}`
);
check(
"closed ones stay out until asked",
!rows.some((row) => row.dataset.row === "c5triage0000")
);
dom.window.document.dispatchEvent(
new dom.window.KeyboardEvent("keydown", {
bubbles: true,
code: "Escape",
key: "Escape",
})
);
flushSync();
await settle();
state.selected = DEEP_PANEL;
flushSync();
await settle();
flushSync();
check(
"header carries the actions menu",
target.querySelector('[aria-label="Conversation actions"]') !== null
);
const scope = (current) => ({
client,
current,
host: { name: "obsidian", openNote: () => {}, touch: false },
onBranch: () => {},
onChanged: () => {},
onOpen: () => {},
onRename: () => {},
});
const labels = async (id, current = true) =>
conversationActions(await client.conversation(id), scope(current)).map(
(a) => a.label
);
const deep = await labels(DEEP_PANEL);
check(
"deep chat offers its note, memory and digest",
deep.includes("Open note") &&
deep.includes("Remember on close") &&
deep.includes("Close with digest"),
deep.join(", ")
);
check(
"a deep chat has no Telegram toggle",
!deep.some((l) => l.includes("Telegram"))
);
const hidden = await labels(BRANCH_BLASTER, false);
check(
"a hidden branch can return to Telegram and be opened",
hidden.includes("Return to Telegram") && hidden.includes("Open"),
hidden.join(", ")
);
const shown = await labels(BRANCH_UFW);
check(
"a visible branch can hide from Telegram and merge",
shown.includes("Hide from Telegram") &&
shown.includes("Merge into parent") &&
!shown.includes("Open"),
shown.join(", ")
);
await unmount(instance);
console.log(
failures.length === 0
? "\nsmoke: all good"
: `\nsmoke: ${failures.length} FAILED`
);
process.exit(failures.length === 0 ? 0 : 1);
+92
View File
@@ -0,0 +1,92 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const source = readFileSync(
join(import.meta.dirname, "../src/tailwind.css"),
"utf8"
);
function block(selector: string): string {
const at = source.indexOf(`\n${selector} {`);
if (at === -1) {
return "";
}
const open = source.indexOf("{", at);
const close = source.indexOf("\n}", open);
return source.slice(open, close);
}
describe("theme aliases", () => {
const OBSIDIAN_VARS = [
"--background-primary",
"--background-secondary",
"--background-modifier-border",
"--text-normal",
"--text-muted",
"--color-accent",
"--text-on-accent",
"--text-error",
];
it("are declared on .beaver-root, not :root", () => {
const root = block(".beaver-root");
expect(root).not.toBe("");
for (const name of OBSIDIAN_VARS) {
expect(root, `${name} must be aliased on .beaver-root`).toContain(name);
}
});
it("never reference Obsidian variables from :root", () => {
const rootBlocks = [...source.matchAll(/(^|\n):root\s*\{([^}]*)\}/g)];
for (const [, , body] of rootBlocks) {
for (const name of OBSIDIAN_VARS) {
expect(body, `:root must not reference ${name}`).not.toContain(name);
}
}
});
it("scopes the dark override under the view root", () => {
expect(source).toContain(".theme-dark .beaver-root");
expect(source).not.toMatch(/\n\.theme-dark\s*\{/);
});
it("keeps the msos palette out: the live accent is Obsidian's", () => {
const root = block(".beaver-root");
expect(root).toContain("--signal: var(--color-accent)");
expect(root).toContain("--primary: var(--color-accent)");
expect(root).not.toMatch(/oklch\([^)]*\s34[0-2]\)/);
});
});
describe("reset specificity", () => {
it("uses a doubled class to outrank app and theme styles", () => {
expect(source).toContain(".beaver-root.beaver-root");
});
it("gives every control Obsidian's corner language", () => {
expect(source).toContain('[data-slot="button"]');
expect(source).toContain("var(--button-radius");
expect(source).toContain('[data-slot="input"]');
});
});
describe("cascade against Obsidian", () => {
it("marks utilities important so they outrank app.css", () => {
expect(source).toMatch(
/@import "tailwindcss\/utilities\.css"[^;]*\bimportant\b/
);
});
it("scans the shared panel sources", () => {
expect(source).toContain('@source "../../beaver-gateway/ui/src/lib"');
expect(source).toContain("panel/panel.css");
});
it("imports only the theme and utility layers", () => {
expect(source).toContain('@import "tailwindcss/theme.css"');
expect(source).toContain('@import "tailwindcss/utilities.css"');
expect(source).not.toMatch(/@import "tailwindcss"\s*[;l]/);
expect(source).not.toMatch(/@import\s+"tailwindcss\/preflight/);
});
});