the officer shell: designs per user, and the handoff desk
manifest, schema, router and two panels. the editor is hosted and saving; the last step is not finished and the readme says so. designs are rows scoped per user, with userId in every WHERE rather than checked after the read — another person's design is 'not found', so the id space cannot be probed. the Doc is stored whole as jsonb because upstream adds fields to it constantly and none of them should be a migration here. the handoff is the reason the plugin exists and it is the part that does not work: /api/chat has NO session-creation route, a session is constructed by the browser opening a websocket, so a plugin has nothing to POST to. onExport returns false and the clipboard still runs, which is upstream's own behaviour — degraded, not broken. docs/handoff.md names the one platform change that would finish it, and argues it should be generic rather than ours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useClient } from "hooks/useClient";
|
||||
import { Canvas, HostProvider, effectivePrompt, titleOf, type Doc, type HostAdapter } from "./adapter";
|
||||
import "./vendor/globals.css";
|
||||
|
||||
/**
|
||||
* The editor, hosted.
|
||||
*
|
||||
* This file is the whole of Officer's contribution to the drawing half: it hands the vendored editor
|
||||
* a place to keep documents and somewhere for the prompt to go, and otherwise stays out of the way.
|
||||
*
|
||||
* ── Saving ──
|
||||
*
|
||||
* Upstream calls `saveDoc` on every change — every drag, every colour, every keystroke in a label. That
|
||||
* is right for `localStorage` and wrong for a database, so writes are debounced and the newest state is
|
||||
* held in a ref rather than in the dependency list. Putting the document in a dependency array would
|
||||
* rebuild the adapter on every stroke, and the editor takes the adapter from context at mount.
|
||||
*
|
||||
* ── Which document ──
|
||||
*
|
||||
* `?doc=<id>` in the URL, per `docs/navigation-audit.md`: selection is addressable, survives a reload,
|
||||
* and can be linked to. Not a channel — a channel would make "which design am I looking at" a thing you
|
||||
* cannot bookmark.
|
||||
*/
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1200;
|
||||
|
||||
type DocumentRow = { id: number; title: string; doc: Partial<Doc>; ui: unknown | null };
|
||||
|
||||
export const CanvasPanel = () => {
|
||||
const client = useClient();
|
||||
// useClient() rebuilds its verbs every render, so it must never sit in a dependency list.
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
const requested = params.get("doc");
|
||||
|
||||
const [row, setRow] = useState<DocumentRow | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
/** The newest document, outside React state — see the note above on why. */
|
||||
const latest = useRef<{ doc: Partial<Doc> | null; ui: unknown }>({ doc: null, ui: null });
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const rowId = useRef<number | null>(null);
|
||||
|
||||
// ── load, or start a new one ────────────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setReady(false);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const found = requested
|
||||
? await clientRef.current.get<DocumentRow>(`/m3e-canvas/documents/${requested}`)
|
||||
: await clientRef.current.get<DocumentRow | null>("/m3e-canvas/documents/latest");
|
||||
|
||||
if (cancelled) return;
|
||||
rowId.current = found?.id ?? null;
|
||||
latest.current = { doc: found?.doc ?? null, ui: found?.ui ?? null };
|
||||
setRow(found ?? null);
|
||||
setError(null);
|
||||
} catch {
|
||||
if (!cancelled) setError("Could not open that design.");
|
||||
} finally {
|
||||
// `ready` gates the editor's first render: mounting it before the document has arrived would
|
||||
// let it start empty and then immediately save that emptiness over the real one.
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [requested]);
|
||||
|
||||
// ── saving ──────────────────────────────────────────────────────────────────────────────────────
|
||||
const flush = useCallback(async () => {
|
||||
const { doc, ui } = latest.current;
|
||||
if (!doc) return;
|
||||
const body = { title: titleOf(doc), doc, ui };
|
||||
|
||||
try {
|
||||
if (rowId.current === null) {
|
||||
const created = await clientRef.current.post<DocumentRow>("/m3e-canvas/documents", body);
|
||||
rowId.current = created.id;
|
||||
// Put the new id in the URL so a reload returns to this design rather than to "latest".
|
||||
setParams((p) => { p.set("doc", String(created.id)); return p; }, { replace: true });
|
||||
} else {
|
||||
await clientRef.current.put(`/m3e-canvas/documents/${rowId.current}`, body);
|
||||
}
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Changes are not being saved.");
|
||||
}
|
||||
}, [setParams]);
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => void flush(), SAVE_DEBOUNCE_MS);
|
||||
}, [flush]);
|
||||
|
||||
// A pending save must not be lost to an unmount — closing the panel is exactly when the last edit
|
||||
// is most likely to be sitting in the timer.
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
void flush();
|
||||
},
|
||||
[flush],
|
||||
);
|
||||
|
||||
// ── the handoff, which is the point of the whole plugin ─────────────────────────────────────────
|
||||
const handoff = useCallback((prompt: string, doc: Doc | null) => {
|
||||
// Fire and forget: the export must not wait on a session starting, and a failure here should not
|
||||
// stop the clipboard from being a working fallback. Returning false does exactly that.
|
||||
void clientRef.current
|
||||
.post("/m3e-canvas/_handoff", { prompt, title: titleOf(doc ?? latest.current.doc) })
|
||||
.catch(() => setError("Could not hand that to the agent — it is on your clipboard instead."));
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Built once. The editor reads this from context at mount, so an identity change mid-edit would
|
||||
// remount it and lose the in-progress design.
|
||||
const host = useMemo<HostAdapter>(
|
||||
() => ({
|
||||
loadDoc: () => latest.current.doc,
|
||||
saveDoc: (doc) => {
|
||||
latest.current.doc = doc;
|
||||
schedule();
|
||||
},
|
||||
loadUi: () => latest.current.ui,
|
||||
saveUi: (ui) => {
|
||||
latest.current.ui = ui;
|
||||
schedule();
|
||||
},
|
||||
onExport: handoff,
|
||||
}),
|
||||
[schedule, handoff],
|
||||
);
|
||||
|
||||
if (!ready) return <Centred>Opening…</Centred>;
|
||||
|
||||
return (
|
||||
<div className="relative size-full">
|
||||
{error ? (
|
||||
<div className="absolute inset-x-0 top-0 z-50 bg-destructive/90 px-3 py-1.5 text-center text-xs text-destructive-foreground">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
<HostProvider value={host}>
|
||||
<Canvas key={row?.id ?? "new"} />
|
||||
</HostProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">{children}</div>
|
||||
);
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { FilePlus2, PenTool, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useClient } from "hooks/useClient";
|
||||
|
||||
/**
|
||||
* The list of designs — the one concept upstream does not have.
|
||||
*
|
||||
* Standalone M3E Canvas holds exactly one document, in one browser, in `localStorage`. Everything here
|
||||
* exists because Officer gives it several, belonging to a person rather than to a browser.
|
||||
*
|
||||
* Selection is `?doc=<id>` in the URL rather than a channel, so a design is addressable and survives a
|
||||
* reload — `docs/navigation-audit.md`. The canvas panel reads the same parameter rather than being
|
||||
* handed the id.
|
||||
*/
|
||||
|
||||
type Row = { id: number; title: string; updatedAt: string };
|
||||
|
||||
export const DesignsPanel = () => {
|
||||
const client = useClient();
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const [rows, setRows] = useState<Row[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [params, setParams] = useSearchParams();
|
||||
const selected = params.get("doc");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setRows(await clientRef.current.get<Row[]>("/m3e-canvas/documents"));
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("Could not load designs.");
|
||||
setRows(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// The canvas creates a row on its first save, so this only has to clear the selection — a design
|
||||
// with nothing drawn in it should not exist, and creating one here would leave empties behind
|
||||
// every time somebody clicked New and changed their mind.
|
||||
const startNew = () => setParams((p) => { p.delete("doc"); return p; }, { replace: true });
|
||||
|
||||
const remove = async (id: number, title: string) => {
|
||||
if (!confirm(`Delete "${title}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
await clientRef.current.delete(`/m3e-canvas/documents/${id}`);
|
||||
if (selected === String(id)) startNew();
|
||||
await load();
|
||||
} catch {
|
||||
setError("Could not delete that design.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
<header className="flex items-center justify-between border-b px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={startNew}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm hover:bg-muted"
|
||||
>
|
||||
<FilePlus2 className="size-4" /> New
|
||||
</button>
|
||||
<button type="button" onClick={() => void load()} className="p-1 text-muted-foreground hover:text-foreground">
|
||||
<RefreshCw className="size-3.5" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error ? <p className="px-3 py-2 text-xs text-destructive">{error}</p> : null}
|
||||
|
||||
{rows === null ? (
|
||||
<Centred>Loading…</Centred>
|
||||
) : rows.length === 0 ? (
|
||||
<Centred>No designs yet. Draw something and it saves itself.</Centred>
|
||||
) : (
|
||||
<ul className="flex-1 overflow-auto">
|
||||
{rows.map((row) => (
|
||||
<li key={row.id} className="group flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParams((p) => { p.set("doc", String(row.id)); return p; }, { replace: true })}
|
||||
className={`flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 ${
|
||||
selected === String(row.id) ? "bg-muted" : ""
|
||||
}`}
|
||||
>
|
||||
<PenTool className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{row.title}</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{new Date(row.updatedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void remove(row.id, row.title)}
|
||||
aria-label={`Delete ${row.title}`}
|
||||
className="px-2 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="flex flex-1 items-center justify-center p-4 text-center text-xs text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* The seam between Officer and the vendored editor.
|
||||
*
|
||||
* **Every import of vendored code goes through this file.** Nothing else in the plugin may reach into
|
||||
* `web/vendor/`, and nothing inside `web/vendor/` may know Officer exists.
|
||||
*
|
||||
* That rule is the entire maintenance strategy. Upstream is days old and committing constantly —
|
||||
* between two commits one day apart it renamed an import, re-indented a 3,000-line component and added
|
||||
* a new guard. When it renames `Inspector` or changes `buildPrompt`'s signature, the repair is this one
|
||||
* file rather than every panel. `scripts/vendor.sh` re-copies, `git diff` shows what really changed,
|
||||
* and any breakage surfaces here.
|
||||
*
|
||||
* Upstream's maintainer reviewed this adapter's shape on lnkiai/m3e-canvas#56, called it sound, and
|
||||
* declined to carry it for now. So it is ours, and it is carried where it costs least.
|
||||
*/
|
||||
|
||||
import type { Doc } from './vendor/lib/tokens';
|
||||
|
||||
/** The editor itself — upstream's `app/page.tsx`, vendored as `Canvas.tsx`. */
|
||||
export { default as Canvas } from './vendor/Canvas';
|
||||
|
||||
/**
|
||||
* The export engine: two pure functions, a `Doc` in and a prompt string out.
|
||||
*
|
||||
* This is the reason the plugin exists. Officer's entire addition is what happens to that string —
|
||||
* upstream writes it to the clipboard, we hand it to an agent.
|
||||
*/
|
||||
export { buildPrompt, effectivePrompt } from './vendor/lib/prompt';
|
||||
|
||||
/** A document is plain serialisable JSON, which is what makes server-side persistence free. */
|
||||
export type { Doc, Frame, Group, Item, Palette, Theme } from './vendor/lib/tokens';
|
||||
|
||||
export type { Lang } from './vendor/lib/i18n';
|
||||
export { isLang, setGlobalLang } from './vendor/lib/i18n';
|
||||
|
||||
/**
|
||||
* The host hooks, wired into the vendored editor by `scripts/apply-host.py`.
|
||||
*
|
||||
* One correction from upstream's review is load-bearing and worth repeating at the point of use:
|
||||
* **branch on whether a hook was supplied, never on what it returned.** A host returning null means
|
||||
* "this user has no saved document", which must start empty — not fall back to whatever the standalone
|
||||
* app left in this browser and then save that into the user's account.
|
||||
*/
|
||||
export { HostProvider, HostContext, useHost } from './vendor/lib/host';
|
||||
export type { HostAdapter } from './vendor/lib/host';
|
||||
|
||||
/** Upstream's stylesheet, imported once by the panel so the editor is drawn in its own vocabulary. */
|
||||
export const VENDOR_STYLESHEET = './vendor/globals.css';
|
||||
|
||||
/**
|
||||
* The title Officer shows in a document list.
|
||||
*
|
||||
* Officer does not otherwise read inside a `Doc` — the schema stores it whole precisely so upstream can
|
||||
* add fields without a migration here. This one reach-in is the exception, and it is in the adapter so
|
||||
* it is the only place that has to change if `title` moves.
|
||||
*/
|
||||
export const titleOf = (doc: Partial<Doc> | null | undefined): string =>
|
||||
(doc && typeof doc.title === 'string' && doc.title.trim()) || 'Untitled';
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// The canvas takes almost everything. Upstream's editor carries its own rails, palette and inspector
|
||||
// inside that pane, so a narrow list beside it is all Officer needs to add — and the list is the only
|
||||
// thing upstream has no concept of, because it assumes one document in one browser.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'm3e-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'm3e-designs', appType: 'm3e-designs' }, size: 18 },
|
||||
{ node: { type: 'panel', id: 'm3e-canvas', appType: 'm3e-canvas' }, size: 82 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PenTool, Files } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { CanvasPanel } from './CanvasPanel';
|
||||
import { DesignsPanel } from './DesignsPanel';
|
||||
|
||||
// Two panels. The canvas is the plugin; the list is how you get between designs.
|
||||
//
|
||||
// `availableOnPanel: false` — PluginScreen renders the workspace locked, so offering these in the
|
||||
// generic picker would be a lie.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'm3e-designs', name: 'Designs', icon: Files, component: DesignsPanel, availableOnPanel: false },
|
||||
{ key: 'm3e-canvas', name: 'Canvas', icon: PenTool, component: CanvasPanel, availableOnPanel: false },
|
||||
];
|
||||
Vendored
-79
@@ -1,79 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { hexToRgb, isHex, onColorFor, rgbToHex, rgbToLab, schemeFromSeed } from "./color";
|
||||
|
||||
/** perceptual lightness of a hex color, for readable-contrast assertions */
|
||||
const toneOf = (hex: string) => rgbToLab(...hexToRgb(hex)!).L;
|
||||
|
||||
describe("isHex", () => {
|
||||
it("accepts a six-digit hex color with a leading #", () => {
|
||||
expect(isHex("#6750A4")).toBe(true);
|
||||
expect(isHex("#ff0088")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects shorthand, missing #, and non-hex input", () => {
|
||||
expect(isHex("#fff")).toBe(false);
|
||||
expect(isHex("6750A4")).toBe(false);
|
||||
expect(isHex("#GGGGGG")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hexToRgb / rgbToHex", () => {
|
||||
it("parses a hex color into channels", () => {
|
||||
expect(hexToRgb("#6750A4")).toEqual([103, 80, 164]);
|
||||
expect(hexToRgb("6750a4")).toEqual([103, 80, 164]);
|
||||
});
|
||||
|
||||
it("round-trips any channel values through both directions", () => {
|
||||
for (const [r, g, b] of [[0, 0, 0], [255, 255, 255], [103, 80, 164], [18, 250, 199]] as const) {
|
||||
expect(hexToRgb(rgbToHex(r, g, b))).toEqual([r, g, b]);
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps channels outside 0..255 when formatting", () => {
|
||||
expect(rgbToHex(300, -4, 7.6)).toBe("#FF0008");
|
||||
});
|
||||
|
||||
it("returns null for input that is not a hex color", () => {
|
||||
expect(hexToRgb("#XYZ123")).toBeNull();
|
||||
expect(hexToRgb("#ABC")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("schemeFromSeed", () => {
|
||||
const seed = "#6750A4";
|
||||
|
||||
it("honours the dark option: surfaces go dark, text on them light", () => {
|
||||
const light = schemeFromSeed(seed);
|
||||
const dark = schemeFromSeed(seed, "Custom", { dark: true });
|
||||
expect(toneOf(dark.surface)).toBeLessThan(toneOf(light.surface));
|
||||
expect(toneOf(dark.onSurface)).toBeGreaterThan(toneOf(light.onSurface));
|
||||
});
|
||||
|
||||
it("honours the contrast option: high contrast pushes the accent further from the surface", () => {
|
||||
const standard = schemeFromSeed(seed);
|
||||
const high = schemeFromSeed(seed, "Custom", { contrast: "high" });
|
||||
expect(high.primary).not.toBe(standard.primary);
|
||||
expect(Math.abs(toneOf(high.primary) - toneOf(high.surface))).toBeGreaterThan(
|
||||
Math.abs(toneOf(standard.primary) - toneOf(standard.surface)),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onColorFor", () => {
|
||||
it("picks a dark on-color on a light background", () => {
|
||||
const on = onColorFor("#FFFFFF");
|
||||
expect(isHex(on)).toBe(true);
|
||||
expect(toneOf(on)).toBeLessThan(40);
|
||||
});
|
||||
|
||||
it("picks a light on-color on a dark background", () => {
|
||||
const on = onColorFor("#000000");
|
||||
expect(isHex(on)).toBe(true);
|
||||
expect(toneOf(on)).toBeGreaterThan(90);
|
||||
});
|
||||
|
||||
it("falls back to black when the background cannot be parsed", () => {
|
||||
expect(onColorFor("nope")).toBe("#000000");
|
||||
});
|
||||
});
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { isLang, LANGS } from "./i18n";
|
||||
|
||||
describe("isLang", () => {
|
||||
it("accepts every language the UI offers", () => {
|
||||
for (const { key } of LANGS) expect(isLang(key)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects anything else", () => {
|
||||
expect(isLang("fr")).toBe(false);
|
||||
expect(isLang(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
Vendored
-111
@@ -1,111 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { Lang, setGlobalLang } from "./i18n";
|
||||
import { buildPrompt } from "./prompt";
|
||||
import { BACK_TARGET, Doc, Item, Platform, defaultTabs, makeItem } from "./tokens";
|
||||
|
||||
const LANGS: Lang[] = ["ja", "en", "zh", "ko"];
|
||||
|
||||
/* Section headings in the order buildPrompt must emit them. */
|
||||
const SECTIONS: Record<Lang, string[]> = {
|
||||
ja: ["## カラー", "## 形・文字・動き", "## 画面構成", "## 振る舞いと画面遷移", "## 各部品のスタイル", "## 全体の指針"],
|
||||
en: ["## Colors", "## Shape, type and motion", "## Layout", "## Behavior and navigation", "## Component styles", "## General guidance"],
|
||||
zh: ["## 配色", "## 形状、字体与动效", "## 屏幕结构", "## 行为与屏幕跳转", "## 各组件的样式", "## 整体原则"],
|
||||
ko: ["## 색상", "## 모양, 글꼴 및 모션", "## 화면 구성", "## 동작 및 화면 전환", "## 부품별 스타일", "## 전체 지침"],
|
||||
};
|
||||
|
||||
const PLATFORM_LINE: Record<Lang, Record<Platform, string>> = {
|
||||
ja: { android: "実装先は Android(ネイティブアプリ)です。", web: "実装先は Web(ブラウザで動くアプリ)です。" },
|
||||
en: { android: "Build it for Android, as a native app.", web: "Build it for the web, as an app that runs in the browser." },
|
||||
zh: { android: "实现目标是 Android(原生应用)。", web: "实现目标是 Web(在浏览器中运行的应用)。" },
|
||||
ko: { android: "Android 네이티브 앱으로 구현한다.", web: "브라우저에서 실행되는 웹 앱으로 구현한다." },
|
||||
};
|
||||
|
||||
/* One phone screen with a top app bar, a connected pair of buttons (one with a
|
||||
* tap action) and a navigation bar. makeItem / defaultTabs fill in the defaults;
|
||||
* module-level language is set first so those defaults follow the test. */
|
||||
function fixture(platform: Platform = "android", extraItems: Item[] = []): Doc {
|
||||
const bar: Item = { ...makeItem("topAppBar"), id: "bar", label: "Home" };
|
||||
const save: Item = { ...makeItem("button"), id: "save", label: "Save", action: { to: BACK_TARGET, transition: "fade" } };
|
||||
const cancel: Item = { ...makeItem("button"), id: "cancel", label: "Cancel", variant: "text" };
|
||||
const nav: Item = { ...makeItem("bottomNav"), id: "nav", tabs: defaultTabs() };
|
||||
return {
|
||||
groups: [
|
||||
{ id: "g-bar", x: 16, y: 24, axis: "x", items: [bar] },
|
||||
{ id: "g-row", x: 16, y: 400, axis: "x", items: [save, cancel, ...extraItems] },
|
||||
{ id: "g-nav", x: 16, y: 812, axis: "x", items: [nav] },
|
||||
],
|
||||
frames: [{ id: "f-home", name: "Home", x: 0, y: 0 }],
|
||||
paletteKey: "purple",
|
||||
frame: "phone",
|
||||
platform,
|
||||
title: "Notes",
|
||||
brief: "",
|
||||
};
|
||||
}
|
||||
|
||||
/* Set the module-level language for the fixture helpers, then build explicitly
|
||||
* in that language — nothing is left to ambient state. */
|
||||
function build(lang: Lang, platform: Platform = "android", extraItems: Item[] = []) {
|
||||
setGlobalLang(lang);
|
||||
return buildPrompt(fixture(platform, extraItems), {}, undefined, lang);
|
||||
}
|
||||
|
||||
const lines = (prompt: string) => prompt.split("\n");
|
||||
const headings = (prompt: string) => lines(prompt).filter((l) => l.startsWith("## "));
|
||||
/* bullet lines between the style heading and the closing guidance heading */
|
||||
function styleBullets(prompt: string, lang: Lang) {
|
||||
const ls = lines(prompt);
|
||||
const style = ls.indexOf(SECTIONS[lang][4]);
|
||||
const general = ls.indexOf(SECTIONS[lang][5]);
|
||||
return ls.slice(style + 1, general).filter((l) => l.startsWith("- "));
|
||||
}
|
||||
|
||||
const QUOTED: Record<Lang, { label: string; others: string[] }> = {
|
||||
ja: { label: "「Save」", others: ['"Save"', "“Save”"] },
|
||||
en: { label: '"Save"', others: ["「Save」", "“Save”"] },
|
||||
zh: { label: "“Save”", others: ["「Save」", '"Save"'] },
|
||||
ko: { label: '"Save"', others: ["「Save」", "“Save”"] },
|
||||
};
|
||||
|
||||
describe("buildPrompt structure", () => {
|
||||
afterEach(() => setGlobalLang("ja")); // restore the module default
|
||||
|
||||
it.each(LANGS)("orders its sections the same way in %s", (lang) => {
|
||||
expect(headings(build(lang))).toEqual(SECTIONS[lang]);
|
||||
});
|
||||
|
||||
it.each(LANGS)("names the requested platform on the intro lines in %s", (lang) => {
|
||||
const android = lines(build(lang, "android"));
|
||||
const web = lines(build(lang, "web"));
|
||||
expect(android[2]).toBe(PLATFORM_LINE[lang].android);
|
||||
expect(web[2]).toBe(PLATFORM_LINE[lang].web);
|
||||
});
|
||||
|
||||
it("names Android when the doc picks no platform", () => {
|
||||
setGlobalLang("en");
|
||||
const { platform, ...doc } = fixture();
|
||||
expect(lines(buildPrompt(doc, {}, undefined, "en"))[2]).toBe(PLATFORM_LINE.en.android);
|
||||
});
|
||||
|
||||
it.each(LANGS)("writes one style note per part kind in use in %s", (lang) => {
|
||||
expect(styleBullets(build(lang), lang)).toHaveLength(3); // topAppBar, button, bottomNav
|
||||
const chip: Item = { ...makeItem("chip"), id: "chip" };
|
||||
expect(styleBullets(build(lang, "android", [chip]), lang)).toHaveLength(4);
|
||||
});
|
||||
|
||||
it.each(LANGS)("quotes labels with %s punctuation", (lang) => {
|
||||
const prompt = build(lang);
|
||||
expect(prompt).toContain(QUOTED[lang].label);
|
||||
for (const other of QUOTED[lang].others) expect(prompt).not.toContain(other);
|
||||
});
|
||||
|
||||
it("follows its lang argument regardless of ambient module state", () => {
|
||||
setGlobalLang("en");
|
||||
const doc = fixture();
|
||||
setGlobalLang("zh");
|
||||
const prompt = buildPrompt(doc, {}, undefined, "ja");
|
||||
expect(headings(prompt)).toEqual(SECTIONS.ja);
|
||||
expect(prompt).toContain("「Save」");
|
||||
});
|
||||
});
|
||||
Vendored
-99
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DURATION_PER_SHAPE_MS, LoadingAnimator, SHAPE_COUNT, Spring, getShapes, morphedShape } from "./shapes";
|
||||
|
||||
describe("getShapes", () => {
|
||||
it("samples every shape in the sequence with the same point count", () => {
|
||||
const shapes = getShapes();
|
||||
expect(shapes).toHaveLength(SHAPE_COUNT);
|
||||
const n = shapes[0].length;
|
||||
expect(n).toBeGreaterThan(0);
|
||||
for (const s of shapes) expect(s).toHaveLength(n);
|
||||
});
|
||||
|
||||
it("normalizes every shape into the unit box, up to resampling drift", () => {
|
||||
for (const shape of getShapes()) {
|
||||
let extreme = 0;
|
||||
for (const [x, y] of shape) {
|
||||
expect(Math.abs(x)).toBeLessThanOrEqual(1 + 1e-9);
|
||||
expect(Math.abs(y)).toBeLessThanOrEqual(1 + 1e-9);
|
||||
extreme = Math.max(extreme, Math.abs(x), Math.abs(y));
|
||||
}
|
||||
/* resampling along the arc can step just past the exact extreme vertex */
|
||||
expect(extreme).toBeGreaterThan(0.99);
|
||||
}
|
||||
});
|
||||
|
||||
it("caches the sampled shapes", () => {
|
||||
expect(getShapes()).toBe(getShapes());
|
||||
});
|
||||
});
|
||||
|
||||
describe("morphedShape", () => {
|
||||
it("returns a shape exactly at every whole fraction", () => {
|
||||
const shapes = getShapes();
|
||||
for (let i = 0; i < SHAPE_COUNT; i++) expect(morphedShape(i)).toEqual(shapes[i]);
|
||||
});
|
||||
|
||||
it("interpolates halfway between neighbours at a half fraction", () => {
|
||||
const [a, b] = getShapes();
|
||||
const mid = morphedShape(0.5);
|
||||
expect(mid[0]).toEqual([(a[0][0] + b[0][0]) / 2, (a[0][1] + b[0][1]) / 2]);
|
||||
});
|
||||
|
||||
it("wraps around the sequence, past both ends", () => {
|
||||
expect(morphedShape(SHAPE_COUNT)).toEqual(getShapes()[0]);
|
||||
const shapes = getShapes();
|
||||
const [last, first] = [shapes[SHAPE_COUNT - 1], shapes[0]];
|
||||
const mid = morphedShape(-0.5);
|
||||
expect(mid[0]).toEqual([(last[0][0] + first[0][0]) / 2, (last[0][1] + first[0][1]) / 2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Spring", () => {
|
||||
it("settles at its target", () => {
|
||||
const s = new Spring(200, 0.6);
|
||||
s.target = 1;
|
||||
for (let i = 0; i < 600; i++) s.step(1 / 60);
|
||||
expect(s.pos).toBeCloseTo(1, 2);
|
||||
expect(Math.abs(s.vel)).toBeLessThan(0.01);
|
||||
});
|
||||
|
||||
it("overshoots when underdamped, not when critically damped", () => {
|
||||
const peakAfter = (damping: number) => {
|
||||
const s = new Spring(200, damping);
|
||||
s.target = 1;
|
||||
let peak = 0;
|
||||
for (let i = 0; i < 120; i++) {
|
||||
s.step(1 / 60);
|
||||
peak = Math.max(peak, s.pos);
|
||||
}
|
||||
return peak;
|
||||
};
|
||||
expect(peakAfter(0.6)).toBeGreaterThan(1);
|
||||
expect(peakAfter(1)).toBeLessThanOrEqual(1.001);
|
||||
});
|
||||
});
|
||||
|
||||
describe("LoadingAnimator", () => {
|
||||
it("advances the morph with time while keeping rotation inside one turn", () => {
|
||||
const a = new LoadingAnimator();
|
||||
for (let ts = 0; ts <= 3 * DURATION_PER_SHAPE_MS; ts += 50) {
|
||||
a.update(ts);
|
||||
expect(a.rotation).toBeGreaterThanOrEqual(0);
|
||||
expect(a.rotation).toBeLessThan(360);
|
||||
}
|
||||
expect(a.morph).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("is deterministic for identical frame sequences", () => {
|
||||
const a = new LoadingAnimator();
|
||||
const b = new LoadingAnimator();
|
||||
for (let ts = 0; ts <= 2000; ts += 33) {
|
||||
a.update(ts);
|
||||
b.update(ts);
|
||||
}
|
||||
expect(a.morph).toBe(b.morph);
|
||||
expect(a.rotation).toBe(b.rotation);
|
||||
});
|
||||
});
|
||||
Vendored
-52
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { tidyFrame } from "./tidy";
|
||||
import { Frame, Group, Item, Kind, NAV_BAR_H, PHONE_H, PHONE_MARGIN, PHONE_W, makeItem } from "./tokens";
|
||||
|
||||
const frame: Frame = { id: "f1", name: "Home", x: 0, y: 0 };
|
||||
const frames = [frame];
|
||||
|
||||
const grp = (id: string, x: number, y: number, items: Item[]): Group => ({ id, x, y, axis: "x", items });
|
||||
const part = (kind: Kind, id: string): Item => ({ ...makeItem(kind), id });
|
||||
|
||||
describe("tidyFrame", () => {
|
||||
it("snaps the app bar to the top edge and the navigation bar to the bottom", () => {
|
||||
const groups = [grp("g-bar", 40, 300, [part("topAppBar", "bar")]), grp("g-nav", 40, 100, [part("bottomNav", "nav")])];
|
||||
const out = tidyFrame(groups, frame, frames, {});
|
||||
const bar = out!.find((g) => g.id === "g-bar")!;
|
||||
const nav = out!.find((g) => g.id === "g-nav")!;
|
||||
expect([bar.x, bar.y]).toEqual([0, 0]);
|
||||
expect([nav.x, nav.y]).toEqual([0, PHONE_H - (80 + NAV_BAR_H)]);
|
||||
});
|
||||
|
||||
it("moves a FAB to the bottom-right corner, one margin in", () => {
|
||||
const out = tidyFrame([grp("g-fab", 40, 100, [part("fab", "fab")])], frame, frames, {});
|
||||
expect([out![0].x, out![0].y]).toEqual([PHONE_W - PHONE_MARGIN - 56, PHONE_H - PHONE_MARGIN - 56]);
|
||||
});
|
||||
|
||||
it("joins neighbouring buttons into one connected run in reading order", () => {
|
||||
const out = tidyFrame([grp("g1", 16, 300, [part("button", "b1")]), grp("g2", 150, 300, [part("button", "b2")])], frame, frames, {});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out![0].items.map((it) => it.id)).toEqual(["b1", "b2"]);
|
||||
});
|
||||
|
||||
it("joins buttons dropped onto each other, in reading order", () => {
|
||||
const out = tidyFrame([grp("g1", 150, 300, [part("button", "b1")]), grp("g2", 100, 304, [part("button", "b2")])], frame, frames, {});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out![0].items.map((it) => it.id)).toEqual(["b2", "b1"]);
|
||||
});
|
||||
|
||||
it("joins neighbouring list items into one connected column", () => {
|
||||
const out = tidyFrame([grp("g1", 16, 100, [part("listItem", "l1")]), grp("g2", 16, 180, [part("listItem", "l2")])], frame, frames, {});
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out![0].axis).toBe("y");
|
||||
expect(out![0].items.map((it) => it.id)).toEqual(["l1", "l2"]);
|
||||
});
|
||||
|
||||
it("returns null for a frame that is already tidy", () => {
|
||||
const messy = [grp("g-bar", 40, 300, [part("topAppBar", "bar")]), grp("g-fab", 10, 10, [part("fab", "fab")])];
|
||||
const once = tidyFrame(messy, frame, frames, {});
|
||||
expect(once).not.toBeNull();
|
||||
expect(tidyFrame(once!, frame, frames, {})).toBeNull();
|
||||
});
|
||||
});
|
||||
Vendored
-69
@@ -1,69 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_THEME, R_FULL, baseRadii, makeItem, normalizeTheme, runCorners, scaleR, setGlobalShape } from "./tokens";
|
||||
|
||||
afterEach(() => setGlobalShape("rounded")); // restore the module default
|
||||
|
||||
describe("setGlobalShape / scaleR", () => {
|
||||
it("shrinks radii for the square scale and grows them for full", () => {
|
||||
setGlobalShape("square");
|
||||
expect(scaleR(R_FULL)).toBe(Math.round(R_FULL * 0.35));
|
||||
setGlobalShape("full");
|
||||
expect(scaleR(R_FULL)).toBe(Math.round(R_FULL * 1.6));
|
||||
setGlobalShape("rounded");
|
||||
expect(scaleR(R_FULL)).toBe(R_FULL);
|
||||
});
|
||||
|
||||
it("flows into a part's default corners", () => {
|
||||
setGlobalShape("rounded");
|
||||
const rounded = baseRadii(makeItem("button")).tl;
|
||||
setGlobalShape("square");
|
||||
expect(baseRadii(makeItem("button")).tl).toBeLessThan(rounded);
|
||||
setGlobalShape("full");
|
||||
expect(baseRadii(makeItem("button")).tl).toBeGreaterThan(rounded);
|
||||
});
|
||||
|
||||
it("keeps a radius the author typed in, whatever the scale", () => {
|
||||
const card = { ...makeItem("card"), radiusTop: 12 };
|
||||
setGlobalShape("full");
|
||||
expect(baseRadii(card)).toEqual({ tl: 12, tr: 12, bl: 12, br: 12 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCorners", () => {
|
||||
const outer = 28;
|
||||
const inner = 8;
|
||||
|
||||
it("puts the outer corners at the ends of a horizontal run, inner between parts", () => {
|
||||
expect(runCorners("x", true, false, outer, inner)).toEqual({ tl: outer, bl: outer, tr: inner, br: inner });
|
||||
expect(runCorners("x", false, true, outer, inner)).toEqual({ tl: inner, bl: inner, tr: outer, br: outer });
|
||||
});
|
||||
|
||||
it("puts the outer corners at the ends of a vertical run, inner between parts", () => {
|
||||
expect(runCorners("y", true, false, outer, inner)).toEqual({ tl: outer, tr: outer, bl: inner, br: inner });
|
||||
expect(runCorners("y", false, true, outer, inner)).toEqual({ tl: inner, tr: inner, bl: outer, br: outer });
|
||||
});
|
||||
|
||||
it("rounds a lone part all over and a middle part nowhere", () => {
|
||||
expect(runCorners("x", true, true, outer, inner)).toEqual({ tl: outer, tr: outer, bl: outer, br: outer });
|
||||
expect(runCorners("y", false, false, outer, inner)).toEqual({ tl: inner, tr: inner, bl: inner, br: inner });
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeTheme", () => {
|
||||
it("returns the defaults untouched for undefined or empty input", () => {
|
||||
expect(normalizeTheme(undefined)).toEqual(DEFAULT_THEME);
|
||||
expect(normalizeTheme({})).toEqual(DEFAULT_THEME);
|
||||
});
|
||||
|
||||
it("keeps the valid fields of a partial theme and fills in the rest", () => {
|
||||
expect(normalizeTheme({ dark: true, shape: "full" })).toEqual({ ...DEFAULT_THEME, dark: true, shape: "full" });
|
||||
});
|
||||
|
||||
it("replaces unknown option values with the default", () => {
|
||||
const t = normalizeTheme({ contrast: "blaring" as never, font: "papyrus" as never, shape: "pointy" as never });
|
||||
expect(t.contrast).toBe(DEFAULT_THEME.contrast);
|
||||
expect(t.font).toBe(DEFAULT_THEME.font);
|
||||
expect(t.shape).toBe(DEFAULT_THEME.shape);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user