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:
2026-09-05 12:16:35 +01:00
co-authored by Claude Fable 5
parent e99b4afbf9
commit a0349cc7a3
17 changed files with 855 additions and 424 deletions
-79
View File
@@ -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");
});
});
-14
View File
@@ -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);
});
});
-111
View File
@@ -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」");
});
});
-99
View File
@@ -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);
});
});
-52
View File
@@ -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();
});
});
-69
View File
@@ -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);
});
});