vendor m3e-canvas 5c83ac6, with the import rewrite scripted

upstream imports through a @/ alias, and this platform already binds
@/* to src/apps/officer-web and @/components/* to its own shared
components — so an unrewritten import resolves to the WRONG module
rather than failing. the rewrite is mechanical and lives in
scripts/vendor.sh so a re-vendor produces a readable diff.

lib/ai.ts and AiPanel are vendored despite being unwanted: deleting them
meant editing vendored code, and Canvas.tsx references them throughout.
a faithful copy carrying an unused module beats a doctored one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 12:00:17 +01:00
co-authored by Claude Fable 5
commit 240d63940d
39 changed files with 17299 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
import { Doc, Frame, Group, Item, frameOfGroup } from "./tokens";
import { buildPrompt } from "./prompt";
import { isProject } from "./project";
import { Lang } from "./i18n";
/* Optional AI helpers. The browser talks to the model provider directly with the
* author's own key; there is no server in between. Every action has a fixed
* prompt and a fixed JSON answer shape, and the result is only applied after the
* author has looked at it. Coordinates are never touched by the model. */
export type Provider = "claude" | "openai" | "gemini" | "deepseek";
export type AiSettings = {
provider: Provider;
baseUrl: string;
model: string;
key: string;
};
export const PROVIDERS: { key: Provider; label: string; baseUrl: string; model: string; keysUrl?: string }[] = [
{ key: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1", model: "gpt-5.6-luna", keysUrl: "https://platform.openai.com/api-keys" },
{ key: "claude", label: "Claude", baseUrl: "https://api.anthropic.com", model: "claude-sonnet-5", keysUrl: "https://console.anthropic.com/settings/keys" },
{ key: "gemini", label: "Gemini", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", model: "gemini-3.8-flash", keysUrl: "https://aistudio.google.com/apikey" },
{ key: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1", model: "deepseek-v4-flash", keysUrl: "https://platform.deepseek.com/api_keys" },
];
export const providerSpec = (k: Provider) => PROVIDERS.find((p) => p.key === k) ?? PROVIDERS[0];
export const DEFAULT_AI: AiSettings = { provider: PROVIDERS[0].key, baseUrl: PROVIDERS[0].baseUrl, model: PROVIDERS[0].model, key: "" };
const STORE_KEY = "m3e:ai";
export function loadAiSettings(): AiSettings {
const s = { ...DEFAULT_AI };
try {
const raw = localStorage.getItem(STORE_KEY);
if (raw) {
const v = JSON.parse(raw) as Partial<AiSettings>;
if (PROVIDERS.some((p) => p.key === v.provider)) s.provider = v.provider as Provider;
if (typeof v.baseUrl === "string") s.baseUrl = v.baseUrl;
if (typeof v.model === "string") s.model = v.model;
if (typeof v.key === "string") s.key = v.key;
}
} catch {}
return s;
}
/** Settings live in this browser only, like the document itself. */
export function saveAiSettings(s: AiSettings) {
try {
localStorage.setItem(STORE_KEY, JSON.stringify(s));
} catch {}
}
const isLocal = (u: string) => /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(u.trim());
/** a hosted endpoint needs a key; a server on this machine may run without one */
export const hasKey = (s: AiSettings) => s.key.trim().length > 0 || isLocal(s.baseUrl);
/** the key must not travel over plain http, except to this machine */
export const isSecureUrl = (u: string) => /^https:\/\//i.test(u.trim()) || isLocal(u);
const trimSlash = (u: string) => u.trim().replace(/\/+$/, "");
async function readError(res: Response): Promise<string> {
let detail = "";
try {
const j = await res.json();
detail = j?.error?.message ?? j?.message ?? JSON.stringify(j);
} catch {
try {
detail = await res.text();
} catch {}
}
return `${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 300)}` : ""}`;
}
/** one round trip: a system prompt and a user message in, the model's text out */
export async function complete(s: AiSettings, system: string, user: string, signal?: AbortSignal, maxTokens = 4096): Promise<string> {
const base = trimSlash(s.baseUrl);
const model = s.model.trim();
if (!model) throw new Error("model");
if (!isSecureUrl(base)) throw new Error("insecure");
if (s.provider === "claude") {
const res = await fetch(`${base}/v1/messages`, {
method: "POST",
signal,
headers: {
"content-type": "application/json",
"x-api-key": s.key.trim(),
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({ model, max_tokens: Math.min(maxTokens, 8192), system, messages: [{ role: "user", content: user }] }),
});
if (!res.ok) throw new Error(await readError(res));
const j = await res.json();
if (j.stop_reason === "refusal") throw new Error("refusal");
if (j.stop_reason === "max_tokens") throw new Error("long");
return (j.content ?? [])
.filter((b: { type: string }) => b.type === "text")
.map((b: { text: string }) => b.text)
.join("");
}
const headers: Record<string, string> = { "content-type": "application/json" };
if (s.key.trim()) headers.authorization = `Bearer ${s.key.trim()}`;
const res = await fetch(`${base}/chat/completions`, {
method: "POST",
signal,
headers,
body: JSON.stringify({
model,
/* OpenAI's newer models refuse `max_tokens` and default generously, so they get no budget;
the other compatible endpoints cap around 8k */
...(s.provider === "openai" ? {} : { max_tokens: Math.min(maxTokens, 8192) }),
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
}),
});
if (!res.ok) throw new Error(await readError(res));
const j = await res.json();
if (j.choices?.[0]?.finish_reason === "length") throw new Error("long");
const c = j.choices?.[0]?.message?.content;
if (typeof c === "string") return c;
if (Array.isArray(c)) return c.map((x: { text?: string }) => x.text ?? "").join("");
throw new Error("empty");
}
/** the first JSON object in a reply, with any code fence stripped */
function parseJsonObject(text: string): Record<string, unknown> {
const cleaned = text.replace(/```(?:json)?/gi, "");
const a = cleaned.indexOf("{");
const b = cleaned.lastIndexOf("}");
if (a < 0 || b <= a) throw new Error("json");
const v = JSON.parse(cleaned.slice(a, b + 1));
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("json");
return v as Record<string, unknown>;
}
/* ---------- actions ---------- */
const LANG_NAME: Record<Lang, string> = { ja: "Japanese", en: "English", zh: "Simplified Chinese", ko: "Korean" };
const hasText = (v?: string | null) => !!v && v.trim().length > 0;
const itemsOnFrame = (doc: Doc, frame: Frame, widths: Record<string, number>): Item[] =>
doc.groups.filter((g: Group) => frameOfGroup(g, doc.frames, widths)?.id === frame.id).flatMap((g) => g.items);
const describeItem = (it: Item) => {
const bits = [`id=${it.id}`, `kind=${it.kind}`];
if (hasText(it.label)) bits.push(`label=${JSON.stringify(it.label)}`);
if (hasText(it.supporting)) bits.push(`supporting=${JSON.stringify(it.supporting)}`);
if (it.icon) bits.push(`icon=${it.icon}`);
if (it.tabs?.length) bits.push(`items=${JSON.stringify(it.tabs.map((t) => t.label || t.icon))}`);
if (it.action) bits.push(`tap=${it.action.to}`);
if (it.toggle) bits.push("toggle");
if (hasText(it.note)) bits.push(`current_note=${JSON.stringify(it.note)}`);
return bits.join(" ");
};
const context = (doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang) =>
[
"The author is sketching a Material 3 Expressive app. Below is the generated description of the whole design, then the screen to work on.",
"",
"=== Whole design ===",
buildPrompt(doc, widths, undefined, lang),
"",
`=== Screen to work on: ${JSON.stringify(frame.name || "(unnamed)")} ===`,
].join("\n");
const SYSTEM = "You help an app designer finish a sketch. Answer with a single JSON object and nothing else: no prose, no markdown fence.";
/** picks the strings the model returned for exactly the parts asked about */
function pickStrings(v: unknown, parts: Item[], max: number): Record<string, string> {
const map = (v ?? {}) as Record<string, unknown>;
const out: Record<string, string> = {};
for (const it of parts) {
const s = map[it.id];
if (typeof s === "string" && s.trim()) out[it.id] = s.trim().slice(0, max);
}
return out;
}
/** a short behavior note for one part; an existing note is refined rather than replaced */
export async function proposeBehavior(s: AiSettings, doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang, itemId: string, signal?: AbortSignal): Promise<string | undefined> {
const parts = itemsOnFrame(doc, frame, widths).filter((it) => it.id === itemId);
if (!parts.length) return undefined;
const user = [
context(doc, widths, frame, lang),
"",
"For the part listed below, write what happens when the user interacts with it: what it does, where it leads, what it shows. One sentence, concrete, in the voice of a product spec (no 'should', no hedging). Infer from the labels, icons and the other screens; do not invent screens that do not exist.",
"A part with a current_note already has the author's own wording: keep its intent and facts, and improve it (clearer, more specific, consistent with the rest of the screen). Do not contradict it.",
`Write in ${LANG_NAME[lang]}.`,
"",
"Part:",
describeItem(parts[0]),
"",
'Answer as {"notes": {"<id>": "<sentence>"}} using exactly the id above.',
].join("\n");
const j = parseJsonObject(await complete(s, SYSTEM, user, signal));
return pickStrings(j.notes, parts, 300)[itemId];
}
/** a name (only when the screen has none) and a one-line purpose for the screen; an existing description is refined */
export async function proposeDescription(s: AiSettings, doc: Doc, widths: Record<string, number>, frame: Frame, lang: Lang, signal?: AbortSignal): Promise<{ name?: string; note: string }> {
const user = [
context(doc, widths, frame, lang),
"",
"Describe this screen's purpose in one or two sentences: who opens it, what they see and what they can do here. Also propose a short screen name (one to three words).",
hasText(frame.note) ? `The author's current description is ${JSON.stringify(frame.note)}: keep its intent and facts, and improve it.` : "",
`Write in ${LANG_NAME[lang]}.`,
"",
'Answer as {"name": "<name>", "description": "<sentences>"}.',
]
.filter((l) => l !== "")
.join("\n");
const j = parseJsonObject(await complete(s, SYSTEM, user, signal));
const note = typeof j.description === "string" ? j.description.trim().slice(0, 400) : "";
if (!note) throw new Error("json");
const name = typeof j.name === "string" ? j.name.trim().slice(0, 40) : "";
return { name: !hasText(frame.name) && name ? name : undefined, note };
}
/** the value a rewritten field had before, so the rewrite can be undone; an empty field leaves nothing to go back to */
export const pushHistory = (history: string[] | undefined, replaced: string | undefined): string[] | undefined => {
const cur = (replaced ?? "").trim();
return cur ? [cur] : history?.length ? history : undefined;
};
/** the patch that swaps a field with what it said before the AI wrote it; pressing again swaps back */
export function popHistory<V extends string, H extends string>(current: string | undefined, history: string[] | undefined, valueKey: V, historyKey: H): Record<V, string> & Record<H, string[] | undefined> {
const [prev] = history ?? [];
const cur = (current ?? "").trim();
return { [valueKey]: prev ?? "", [historyKey]: cur ? [cur] : undefined } as Record<V, string> & Record<H, string[] | undefined>;
}
/** A whole design from an idea, drafted by the author's own model. `guide` is the same
* agent guide a coding agent reads (public/agent.md), so both paths follow one spec.
* The answer is the document itself; a link would be pointless here. */
export async function draftDesign(s: AiSettings, guide: string, idea: string, lang: Lang, signal?: AbortSignal): Promise<Doc> {
const system = [
"You draft M3E Canvas designs. Follow the guide below exactly.",
"Reply with the JSON document only: no share link, no prose, no markdown fence, no explanation.",
"",
guide,
].join("\n");
const user = [`Sketch this app: ${idea.trim()}`, `Write every label, title and note in ${LANG_NAME[lang]}.`, "Three to five screens. Keep it simple."].join("\n");
const j = parseJsonObject(await complete(s, system, user, signal, 12000));
if (!isProject(j)) throw new Error("json");
return j;
}
+79
View File
@@ -0,0 +1,79 @@
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");
});
});
+182
View File
@@ -0,0 +1,182 @@
/**
* A small Material-style scheme generator: one seed color becomes a light
* color scheme by placing each role at a fixed tone (CIE L*) with the seed's
* hue and a role-specific chroma, then clipping chroma into the sRGB gamut.
* It follows the shape of Material's HCT tonal palettes without the full
* CAM16 model, which is more than enough for a mockup palette.
*/
import type { Palette } from "./tokens";
export type Lab = { L: number; a: number; b: number };
export type Lch = { L: number; C: number; h: number };
const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
export function hexToRgb(hex: string): [number, number, number] | null {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export const rgbToHex = (r: number, g: number, b: number) =>
"#" + [r, g, b].map((v) => Math.round(clamp01(v / 255) * 255).toString(16).padStart(2, "0")).join("").toUpperCase();
const lin = (c: number) => {
const v = c / 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
const gam = (v: number) => (v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055);
const WHITE = [0.95047, 1, 1.08883];
const f = (t: number) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fInv = (t: number) => (t > 0.2069 ? t * t * t : (t - 16 / 116) / 7.787);
export function rgbToLab(r: number, g: number, b: number): Lab {
const R = lin(r);
const G = lin(g);
const B = lin(b);
const x = (R * 0.4124 + G * 0.3576 + B * 0.1805) / WHITE[0];
const y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / WHITE[1];
const z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / WHITE[2];
const fx = f(x);
const fy = f(y);
const fz = f(z);
return { L: 116 * fy - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
}
/** sRGB in 0..255, or null when the color is outside the gamut */
function labToRgb(lab: Lab): [number, number, number] | null {
const fy = (lab.L + 16) / 116;
const fx = lab.a / 500 + fy;
const fz = fy - lab.b / 200;
const x = fInv(fx) * WHITE[0];
const y = fInv(fy) * WHITE[1];
const z = fInv(fz) * WHITE[2];
const R = x * 3.2406 + y * -1.5372 + z * -0.4986;
const G = x * -0.9689 + y * 1.8758 + z * 0.0415;
const B = x * 0.0557 + y * -0.204 + z * 1.057;
const out = [R, G, B].map(gam);
if (out.some((v) => v < -0.002 || v > 1.002)) return null;
return out.map((v) => clamp01(v) * 255) as [number, number, number];
}
export function labToLch(lab: Lab): Lch {
const C = Math.hypot(lab.a, lab.b);
let h = (Math.atan2(lab.b, lab.a) * 180) / Math.PI;
if (h < 0) h += 360;
return { L: lab.L, C, h };
}
/** the color at a tone / chroma / hue, with chroma reduced until it fits sRGB */
export function tone(L: number, C: number, h: number): string {
const rad = (h * Math.PI) / 180;
let c = C;
for (let i = 0; i < 40; i++) {
const rgb = labToRgb({ L, a: c * Math.cos(rad), b: c * Math.sin(rad) });
if (rgb) return rgbToHex(rgb[0], rgb[1], rgb[2]);
c *= 0.88;
}
const rgb = labToRgb({ L, a: 0, b: 0 }) ?? [128, 128, 128];
return rgbToHex(rgb[0], rgb[1], rgb[2]);
}
export type Contrast = "standard" | "medium" | "high";
export type SchemeOptions = { dark?: boolean; contrast?: Contrast; /** keep a muted seed muted instead of lifting it to a vivid accent */ keepChroma?: boolean };
/** tone (CIE L*) of every role, light and dark, at each contrast level.
* The standard tones are Material's; medium and high push the accents and
* outlines further from their backgrounds the way Material Theme Builder does. */
type Tones = Record<
| "primary" | "onPrimary" | "primaryContainer" | "onPrimaryContainer" | "inversePrimary"
| "secondaryContainer" | "onSecondaryContainer" | "tertiaryContainer" | "onTertiaryContainer"
| "surface" | "surfaceContainerLow" | "surfaceContainer" | "surfaceContainerHigh" | "surfaceContainerHighest"
| "onSurface" | "onSurfaceVariant" | "outline" | "outlineVariant" | "inverseSurface" | "inverseOnSurface",
number
>;
const LIGHT: Tones = {
primary: 40, onPrimary: 100, primaryContainer: 90, onPrimaryContainer: 10, inversePrimary: 80,
secondaryContainer: 90, onSecondaryContainer: 10, tertiaryContainer: 90, onTertiaryContainer: 10,
surface: 98, surfaceContainerLow: 96, surfaceContainer: 94, surfaceContainerHigh: 92, surfaceContainerHighest: 90,
onSurface: 10, onSurfaceVariant: 30, outline: 50, outlineVariant: 80, inverseSurface: 20, inverseOnSurface: 95,
};
const DARK: Tones = {
primary: 80, onPrimary: 20, primaryContainer: 30, onPrimaryContainer: 90, inversePrimary: 40,
secondaryContainer: 30, onSecondaryContainer: 90, tertiaryContainer: 30, onTertiaryContainer: 90,
surface: 6, surfaceContainerLow: 10, surfaceContainer: 12, surfaceContainerHigh: 17, surfaceContainerHighest: 22,
onSurface: 90, onSurfaceVariant: 80, outline: 60, outlineVariant: 30, inverseSurface: 90, inverseOnSurface: 20,
};
function tonesFor(dark: boolean, contrast: Contrast): Tones {
const t = { ...(dark ? DARK : LIGHT) };
if (contrast === "medium") {
if (dark) Object.assign(t, { primary: 85, onPrimaryContainer: 95, onSecondaryContainer: 95, onTertiaryContainer: 95, onSurfaceVariant: 85, outline: 70, outlineVariant: 50 });
else Object.assign(t, { primary: 30, onPrimaryContainer: 20, onSecondaryContainer: 20, onTertiaryContainer: 20, onSurfaceVariant: 25, outline: 40, outlineVariant: 65 });
} else if (contrast === "high") {
if (dark) Object.assign(t, { primary: 95, onPrimary: 0, primaryContainer: 80, onPrimaryContainer: 0, secondaryContainer: 80, onSecondaryContainer: 0, tertiaryContainer: 80, onTertiaryContainer: 0, onSurface: 100, onSurfaceVariant: 95, outline: 90, outlineVariant: 90 });
else Object.assign(t, { primary: 20, primaryContainer: 30, onPrimaryContainer: 100, secondaryContainer: 30, onSecondaryContainer: 100, tertiaryContainer: 30, onTertiaryContainer: 100, onSurface: 0, onSurfaceVariant: 10, outline: 20, outlineVariant: 20 });
}
return t;
}
const ERROR_LIGHT = { error: "#B3261E", onError: "#FFFFFF", errorContainer: "#F9DEDC", onErrorContainer: "#410E0B" };
const ERROR_DARK = { error: "#F2B8B5", onError: "#601410", errorContainer: "#8C1D18", onErrorContainer: "#F9DEDC" };
/** Material 3 scheme from a seed color: light by default, dark and higher
* contrast on request. */
export function schemeFromSeed(seedHex: string, label = "Custom", opts: SchemeOptions = {}): Palette {
const rgb = hexToRgb(seedHex) ?? [103, 80, 164];
const lch = labToLch(rgbToLab(rgb[0], rgb[1], rgb[2]));
const h = lch.h;
const primaryC = opts.keepChroma ? Math.min(lch.C, 60) : Math.max(36, Math.min(lch.C, 60));
const secondaryC = primaryC / 3;
const tertiaryH = (h + 60) % 360;
const tertiaryC = primaryC / 2;
const neutralC = 3;
const neutralVarC = 7;
const P = (L: number) => tone(L, primaryC, h);
const S = (L: number) => tone(L, secondaryC, h);
const T = (L: number) => tone(L, tertiaryC, tertiaryH);
const N = (L: number) => tone(L, neutralC, h);
const NV = (L: number) => tone(L, neutralVarC, h);
const dark = !!opts.dark;
const k = tonesFor(dark, opts.contrast ?? "standard");
return {
key: "custom",
label,
seed: seedHex.toUpperCase(),
primary: P(k.primary),
onPrimary: P(k.onPrimary),
primaryContainer: P(k.primaryContainer),
onPrimaryContainer: P(k.onPrimaryContainer),
inversePrimary: P(k.inversePrimary),
secondaryContainer: S(k.secondaryContainer),
onSecondaryContainer: S(k.onSecondaryContainer),
tertiaryContainer: T(k.tertiaryContainer),
onTertiaryContainer: T(k.onTertiaryContainer),
surface: N(k.surface),
surfaceContainerLow: N(k.surfaceContainerLow),
surfaceContainer: N(k.surfaceContainer),
surfaceContainerHigh: N(k.surfaceContainerHigh),
surfaceContainerHighest: N(k.surfaceContainerHighest),
onSurface: N(k.onSurface),
onSurfaceVariant: NV(k.onSurfaceVariant),
outline: NV(k.outline),
outlineVariant: NV(k.outlineVariant),
inverseSurface: N(k.inverseSurface),
inverseOnSurface: N(k.inverseOnSurface),
...(dark ? ERROR_DARK : ERROR_LIGHT),
};
}
/** readable text color for an arbitrary background: the same hue at tone 10 or 100 */
export function onColorFor(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return "#000000";
const lch = labToLch(rgbToLab(rgb[0], rgb[1], rgb[2]));
return lch.L > 60 ? tone(10, Math.min(lch.C, 30), lch.h) : tone(100, 0, lch.h);
}
export const isHex = (v: string) => /^#[0-9a-f]{6}$/i.test(v.trim());
+14
View File
@@ -0,0 +1,14 @@
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);
});
});
+684
View File
@@ -0,0 +1,684 @@
"use client";
import { createContext, useContext } from "react";
export type Lang = "ja" | "en" | "zh" | "ko";
export const LANGS: { key: Lang; label: string }[] = [
{ key: "ja", label: "日本語" },
{ key: "en", label: "English" },
{ key: "zh", label: "中文" },
{ key: "ko", label: "한국어" },
];
export const isLang = (v: unknown): v is Lang => v === "ja" || v === "en" || v === "zh" || v === "ko";
/* A module-level copy lets non-React helpers (item defaults, prompt text)
* follow the language without threading it through every call. */
let current: Lang = "ja";
export const getLang = () => current;
export const setGlobalLang = (l: Lang) => {
current = l;
};
export const LangContext = createContext<Lang>("ja");
export const useLang = () => useContext(LangContext);
export const SEED_TEXT: Record<Lang, { favorite: string; share: string; inbox: string; starred: string; archive: string; supporting: string; start: string }> = {
ja: { favorite: "お気に入り", share: "共有", inbox: "受信トレイ", starred: "スター付き", archive: "アーカイブ", supporting: "サブテキスト", start: "はじめる" },
en: { favorite: "Favorite", share: "Share", inbox: "Inbox", starred: "Starred", archive: "Archive", supporting: "Supporting text", start: "Get started" },
zh: { favorite: "收藏", share: "分享", inbox: "收件箱", starred: "已加星标", archive: "归档", supporting: "辅助文本", start: "开始" },
ko: { favorite: "즐겨찾기", share: "공유", inbox: "받은편지함", starred: "별표 표시", archive: "보관함", supporting: "보조 텍스트", start: "시작하기" },
};
/** ponytail: matches defaults by text; add provenance if authored copies must be distinguished. */
export function translateDefaultText(value: string, kind: string, field: "label" | "supporting" | "tab", lang: Lang): string {
for (const { key: from } of LANGS) {
if (field === "tab") {
const labels = (l: Lang) => kind === "tabs" ? TAB_LABELS[l] : (kind === "fabMenu" ? FAB_MENU_TABS[l] : NAV_TABS[l]).map((tab) => tab.label);
const index = labels(from).indexOf(value);
if (index >= 0) return labels(lang)[index] ?? value;
} else {
if (value && value === KIND_TEXT[from][kind]?.[field]) return KIND_TEXT[lang][kind]?.[field] ?? value;
const keys: (keyof typeof SEED_TEXT.en)[] = field === "supporting" ? ["supporting"] : kind === "button" ? ["favorite", "share", "start"] : kind === "listItem" ? ["inbox", "starred", "archive"] : [];
for (const key of keys) {
if (value === SEED_TEXT[from][key]) return SEED_TEXT[lang][key];
}
}
}
return value;
}
type Str = { ja: string; en: string; zh: string };
export function translateDefaultFrameName(name: string, lang: Lang): string {
for (const { key } of LANGS) {
if (name === t("home", key)) return t("home", lang);
const prefix = `${t("screenN", key)} `;
if (name.startsWith(prefix) && /^\d+$/.test(name.slice(prefix.length))) {
return `${t("screenN", lang)} ${name.slice(prefix.length)}`;
}
}
return name;
}
export const COLOR_TOKEN_TEXT = {
ja: { surface: "サーフェス", surfaceContainerLow: "コンテナ(低)", surfaceContainer: "コンテナ", surfaceContainerHigh: "コンテナ(高)", surfaceContainerHighest: "コンテナ(最高)", primaryContainer: "プライマリコンテナ", secondaryContainer: "セカンダリコンテナ", tertiaryContainer: "ターシャリコンテナ", primary: "プライマリ", inverseSurface: "反転サーフェス" },
zh: { surface: "表面", surfaceContainerLow: "低层容器", surfaceContainer: "容器", surfaceContainerHigh: "高层容器", surfaceContainerHighest: "最高层容器", primaryContainer: "主色容器", secondaryContainer: "次色容器", tertiaryContainer: "第三色容器", primary: "主色", inverseSurface: "反色表面" },
ko: { surface: "표면", surfaceContainerLow: "낮은 컨테이너", surfaceContainer: "컨테이너", surfaceContainerHigh: "높은 컨테이너", surfaceContainerHighest: "가장 높은 컨테이너", primaryContainer: "주 색상 컨테이너", secondaryContainer: "보조 색상 컨테이너", tertiaryContainer: "세 번째 색상 컨테이너", primary: "주 색상", inverseSurface: "반전 표면" },
};
const UI = {
// panels
parts: { ja: "部品", en: "Parts", zh: "组件" },
layers: { ja: "レイヤー", en: "Layers", zh: "图层" },
edit: { ja: "編集", en: "Edit", zh: "编辑" },
prompt: { ja: "プロンプト", en: "Prompt", zh: "提示词" },
closePanel: { ja: "パネルを閉じる", en: "Close panel", zh: "关闭面板" },
search: { ja: "検索", en: "Search", zh: "搜索" },
favorites: { ja: "お気に入り", en: "Favorites", zh: "收藏" },
addFavorite: { ja: "お気に入りに追加", en: "Add to favorites", zh: "添加到收藏" },
removeFavorite: { ja: "お気に入りから外す", en: "Remove from favorites", zh: "取消收藏" },
clear: { ja: "クリア", en: "Clear", zh: "清除" },
language: { ja: "言語", en: "Language", zh: "语言" },
// toolbar
select: { ja: "選択 (V)", en: "Select (V)", zh: "选择 (V)" },
hand: { ja: "手のひら (H / Space)", en: "Hand (H / Space)", zh: "抓手 (H / Space)" },
blank: { ja: "白紙", en: "Blank canvas", zh: "空白画布" },
phone: { ja: "スマホ画面", en: "Phone screens", zh: "手机屏幕" },
addFrame: { ja: "画面を追加", en: "Add screen", zh: "添加屏幕" },
preview: { ja: "プレビュー (P)", en: "Preview (P)", zh: "预览 (P)" },
zoomIn: { ja: "拡大 (+)", en: "Zoom in (+)", zh: "放大 (+)" },
zoomOut: { ja: "縮小 (-)", en: "Zoom out (-)", zh: "缩小 (-)" },
fit: { ja: "全体を表示 (0)", en: "Fit (0)", zh: "适应窗口 (0)" },
undo: { ja: "元に戻す (Ctrl+Z)", en: "Undo (Ctrl+Z)", zh: "撤销 (Ctrl+Z)" },
redo: { ja: "やり直す (Ctrl+Shift+Z)", en: "Redo (Ctrl+Shift+Z)", zh: "重做 (Ctrl+Shift+Z)" },
clearAll: { ja: "すべて消す", en: "Clear canvas", zh: "全部清除" },
clearAllTitle: { ja: "すべて消しますか?", en: "Clear the canvas?", zh: "要全部清除吗?" },
clearAllBody: {
ja: "すべての画面と部品を削除します。元に戻す (Ctrl+Z) で復元できます。",
en: "Every screen and part will be removed. Undo (Ctrl+Z) can bring them back.",
zh: "将删除所有屏幕和组件。可以用撤销 (Ctrl+Z) 恢复。",
},
// inspector
screen: { ja: "画面", en: "Screen", zh: "屏幕" },
screenName: { ja: "画面の名前", en: "Screen name", zh: "屏幕名称" },
name: { ja: "名前", en: "Name", zh: "名称" },
background: { ja: "背景", en: "Background", zh: "背景" },
export: { ja: "書き出し", en: "Export", zh: "导出" },
project: { ja: "プロジェクト", en: "Project", zh: "项目" },
saveProject: { ja: "プロジェクトを保存", en: "Save project", zh: "保存项目" },
openProject: { ja: "プロジェクトを開く", en: "Open project", zh: "打开项目" },
replaceProjectTitle: { ja: "プロジェクトを開きますか?", en: "Open this project?", zh: "要打开这个项目吗?" },
askAi: { ja: "AI に頼む", en: "Ask an AI", zh: "让 AI 来画" },
askAiHint: {
ja: "作りたいものを書いて「AI で作る」を押すと、AI タブのキーで設計がキャンバスに描かれます。代わりに指示をコピーして Claude Code などの AI エージェントに渡すこともできます。返ってきたリンクを開くか、JSON をファイルに保存して「プロジェクトを開く」から読み込みます。",
en: "Describe what you want and press Draft with AI: the model from the AI tab draws the design on the canvas. Or copy the instruction for an AI agent such as Claude Code; open the link it replies with, or save its JSON and load it with Open project.",
zh: "写下想做的东西并按“让 AI 生成”,AI 标签页里的模型会把设计画到画布上。也可以复制指令交给 Claude Code 等 AI 代理:打开它回复的链接,或把 JSON 保存为文件后用“打开项目”读取。",
},
askAiIdea: { ja: "作りたいもの(例: レシピを保存して検索できるアプリ)", en: "What to build (e.g. an app to save and search recipes)", zh: "想做的东西(例如:能保存和搜索菜谱的应用)" },
askAiIdeaFallback: { ja: "(ここに作りたいものを書く)", en: "(describe what to build here)", zh: "(在这里写下想做的东西)" },
askAiCopy: { ja: "指示をコピー", en: "Copy the instruction", zh: "复制指令" },
askAiText: {
ja: "M3E Canvas のスケッチを作ってください。まず {url} を読み、その手順どおりに設計を JSON で組み立てて、共有リンクの形で返してください。コードを実行できない場合は、設計の JSON をコードブロックで返してください(ファイルに保存して読み込みます)。検証は不要です。\n\n作りたいもの: {idea}",
en: "Make an M3E Canvas sketch. First read {url} and follow it: build the design as JSON and reply with a share link. If you cannot run code, reply with the JSON in a code block (it will be saved to a file and opened). No verification is needed.\n\nWhat to build: {idea}",
zh: "请制作一个 M3E Canvas 草图。先阅读 {url},按其中的步骤用 JSON 组织设计,并以分享链接的形式回复。如果无法运行代码,就在代码块中回复设计的 JSON(会保存为文件后打开)。不需要验证。\n\n想做的东西:{idea}",
},
askAiGenerate: { ja: "AI で作る", en: "Draft with AI", zh: "让 AI 生成" },
askAiPasted: { ja: "AI エージェントに貼り付けてください", en: "Paste it into your AI agent", zh: "请粘贴到 AI 代理中" },
askAiTitle: { ja: "AI に設計を頼む", en: "Ask an AI for a design", zh: "让 AI 设计" },
askAiCopyTitle: { ja: "AI エージェント用の指示", en: "Instruction for an AI agent", zh: "AI 代理用指令" },
askAiGenerateTitle: { ja: "AI タブのキーで作る", en: "Uses the key in the AI tab", zh: "使用 AI 标签页的密钥" },
aiSetup: { ja: "AI の設定", en: "AI settings", zh: "AI 设置" },
aiSetupHint: { ja: "キーを入れると、ここで直接作れます", en: "Add a key to draft it right here", zh: "填写密钥后可在此直接生成" },
aiSetupTitle: { ja: "AI タブを開く", en: "Open the AI tab", zh: "打开 AI 标签页" },
draftKeep: { ja: "この設計を採用", en: "Keep this design", zh: "采用这个设计" },
draftUndo: { ja: "前の設計に戻す", en: "Back to the previous design", zh: "恢复之前的设计" },
askAiGenerating: { ja: "作成中…", en: "Drafting…", zh: "生成中…" },
selectedTab: { ja: "選択中にする", en: "Make this the selected one", zh: "设为选中项" },
shareLinkCopy: { ja: "リンクをコピー", en: "Copy link", zh: "复制链接" },
shareLinkHint: { ja: "この設計を開くリンク(画像は含まない)", en: "A link that opens this design (no images)", zh: "打开此设计的链接(不含图片)" },
replaceProject: {
ja: "現在のキャンバスは置き換わります。元に戻す (Ctrl+Z) で戻せます。",
en: "The current canvas will be replaced. Undo (Ctrl+Z) brings it back.",
zh: "当前画布将被替换。可以用撤销 (Ctrl+Z) 恢复。",
},
invalidProject: {
ja: "プロジェクトファイルを開けませんでした。",
en: "Could not open the project file.",
zh: "无法打开项目文件。",
},
readOnlyTitle: { ja: "別のタブで編集中です", en: "Editing in another tab", zh: "正在其他标签页中编辑" },
readOnlyBody: {
ja: "このキャンバスは別のタブで編集中です。そのタブを閉じてから、このページを再読み込みしてください。",
en: "This canvas is being edited in another tab. Close that tab, then reload this page to edit.",
zh: "此画布正在其他标签页中编辑。关闭该标签页后,重新加载此页面即可编辑。",
},
reload: { ja: "再読み込み", en: "Reload", zh: "重新加载" },
copied: { ja: "コピーしました", en: "Copied", zh: "已复制" },
saveImage: { ja: "画像で保存", en: "Save as image", zh: "保存为图片" },
saving: { ja: "保存中…", en: "Saving…", zh: "保存中…" },
previewFrom: { ja: "この画面からプレビュー", en: "Preview from this screen", zh: "从此屏幕预览" },
duplicate: { ja: "複製", en: "Duplicate", zh: "复制" },
duplicateKey: { ja: "複製 (Ctrl+D)", en: "Duplicate (Ctrl+D)", zh: "复制 (Ctrl+D)" },
delete: { ja: "削除 (Delete)", en: "Delete (Delete)", zh: "删除 (Delete)" },
deleteSelection: { ja: "選択を削除", en: "Delete selection", zh: "删除所选" },
text: { ja: "テキスト", en: "Text", zh: "文本" },
label: { ja: "ラベル", en: "Label", zh: "标签" },
bold: { ja: "太字", en: "Bold", zh: "粗体" },
action: { ja: "アクション", en: "Action", zh: "操作" },
supporting: { ja: "サブテキスト", en: "Supporting text", zh: "辅助文本" },
tabs: { ja: "項目", en: "Items", zh: "项目" },
changeIcon: { ja: "アイコンを変更", en: "Change icon", zh: "更改图标" },
image: { ja: "画像", en: "Image", zh: "图片" },
pickImage: { ja: "画像を選ぶ", en: "Choose image", zh: "选择图片" },
removeImage: { ja: "画像を外す", en: "Remove image", zh: "移除图片" },
imageUrl: { ja: "画像の URL", en: "Image URL", zh: "图片网址" },
imageArea: { ja: "画像エリア", en: "Image area", zh: "图片区域" },
autoWidth: { ja: "文字の幅", en: "Text width", zh: "随文字" },
icon: { ja: "アイコン", en: "Icon", zh: "图标" },
noIcon: { ja: "アイコンなし", en: "No icon", zh: "无图标" },
searchIcons: { ja: "アイコンを検索", en: "Search icons", zh: "搜索图标" },
style: { ja: "スタイル", en: "Style", zh: "样式" },
filled: { ja: "塗りつぶし", en: "Filled", zh: "填充" },
tonal: { ja: "トーナル", en: "Tonal", zh: "色调" },
elevated: { ja: "浮き上がり", en: "Elevated", zh: "凸起" },
outlined: { ja: "枠線", en: "Outlined", zh: "描边" },
standard: { ja: "標準", en: "Standard", zh: "标准" },
vibrant: { ja: "鮮やか", en: "Vibrant", zh: "鲜艳" },
state: { ja: "状態", en: "State", zh: "状态" },
selected: { ja: "選択", en: "Selected", zh: "已选中" },
handle: { ja: "ハンドル(ボトムシート)", en: "Handle (bottom sheet)", zh: "拖动条(底部面板)" },
listSwitch: { ja: "末尾にスイッチ", en: "Trailing switch", zh: "末尾开关" },
on: { ja: "オン", en: "On", zh: "开" },
container: { ja: "コンテナ", en: "Container", zh: "容器" },
wavy: { ja: "波形", en: "Wavy", zh: "波浪形" },
determinate: { ja: "確定", en: "Determinate", zh: "确定进度" },
size: { ja: "サイズ", en: "Size", zh: "尺寸" },
width: { ja: "幅", en: "Width", zh: "宽度" },
height: { ja: "高さ", en: "Height", zh: "高度" },
fontSize: { ja: "文字サイズ", en: "Font size", zh: "字号" },
cornerRadius: { ja: "角丸", en: "Corner radius", zh: "圆角" },
cornerTop: { ja: "上の角丸", en: "Top corners", zh: "上方圆角" },
cornerBottom: { ja: "下の角丸", en: "Bottom corners", zh: "下方圆角" },
cornerLeft: { ja: "左の角丸", en: "Left corners", zh: "左侧圆角" },
cornerRight: { ja: "右の角丸", en: "Right corners", zh: "右侧圆角" },
cornersEach: { ja: "角ごとに指定", en: "Each corner", zh: "分别设置各角" },
cornerTl: { ja: "左上", en: "Top left", zh: "左上" },
cornerTr: { ja: "右上", en: "Top right", zh: "右上" },
cornerBl: { ja: "左下", en: "Bottom left", zh: "左下" },
cornerBr: { ja: "右下", en: "Bottom right", zh: "右下" },
screenWidth: { ja: "画面いっぱい", en: "Screen width", zh: "全屏宽" },
contentWidth: { ja: "左右 16dp の余白", en: "16dp side margins", zh: "左右 16dp 边距" },
halfWidth: { ja: "2 列に並べる幅", en: "Half a row (two columns)", zh: "两列宽" },
columnWidth: { ja: "スマホ 1 画面分の幅", en: "One phone column", zh: "一个手机屏幕的宽度" },
screenHeight: { ja: "画面の高さ", en: "Screen height", zh: "全屏高" },
halfHeight: { ja: "画面の半分", en: "Half the screen", zh: "半屏高" },
tapTo: { ja: "タップで移動", en: "Tap to open", zh: "点击跳转" },
none: { ja: "なし", en: "None", zh: "无" },
goBack: { ja: "戻る", en: "Back", zh: "返回" },
swipeTo: { ja: "スワイプで移動", en: "Swipe to open", zh: "滑动跳转" },
toggle: { ja: "切り替えボタン", en: "Toggle button", zh: "切换按钮" },
toggleHint: { ja: "タップでオン/オフ", en: "Tap toggles on / off", zh: "点击切换开/关" },
thumbCheck: { ja: "オンのときチェックアイコン", en: "Check icon when on", zh: "开启时显示勾选图标" },
behavior: { ja: "振る舞い", en: "Behavior", zh: "行为" },
whenPressed: { ja: "押したとき…", en: "When pressed…", zh: "按下时…" },
whatItDoes: { ja: "この部品の動き…", en: "What this part does…", zh: "这个组件的作用…" },
removeLink: { ja: "リンクを外す", en: "Remove link", zh: "移除链接" },
group: { ja: "グループ", en: "Group", zh: "组" },
makeGroup: { ja: "グループ化", en: "Group", zh: "编组" },
ungroup: { ja: "グループ解除", en: "Ungroup", zh: "取消编组" },
selectedParts: { ja: "個を選択中", en: "selected", zh: "个已选中" },
groupHint: {
ja: "重なりを保ったまま、ひとつのレイヤーとして一緒に動かせます",
en: "Keeps the overlap and moves as one layer",
zh: "保持叠放关系,作为一个图层一起移动",
},
iconBackground: { ja: "アイコンの背景", en: "Icon background", zh: "图标背景" },
noBackground: { ja: "背景なし", en: "No background", zh: "无背景" },
normalState: { ja: "通常", en: "Normal", zh: "常态" },
onState: { ja: "オン", en: "On", zh: "开启" },
onStateHint: { ja: "オンのときの文字・アイコン・スタイル", en: "Text, icon and style when on", zh: "开启时的文字、图标和样式" },
groupEditNote: { ja: "中の部品を編集するにはグループを解除してください", en: "Ungroup to edit the parts inside", zh: "要编辑其中的组件,请先取消编组" },
openPanel: { ja: "パネルを開く", en: "Open panel", zh: "打开面板" },
colors: { ja: "カラー", en: "Colors", zh: "配色" },
templates: { ja: "パレット", en: "Palettes", zh: "调色板" },
customColor: { ja: "カスタム", en: "Custom", zh: "自定义" },
seedColor: { ja: "ベースの色", en: "Seed color", zh: "基准色" },
seedHint: {
ja: "1 色選ぶと Material 3 のスキーム全体を作ります。細かく調整で個別の色も変えられます。",
en: "One color builds the whole Material 3 scheme. Fine-tune changes single roles.",
zh: "选一个颜色即可生成整套 Material 3 配色。可在微调中单独修改。",
},
useThis: { ja: "この色にする", en: "Use it", zh: "使用" },
fineTune: { ja: "細かく調整", en: "Fine-tune", zh: "微调" },
dynamicColor: { ja: "ダイナミックカラー", en: "Dynamic color", zh: "动态配色" },
dynamicOnHint: {
ja: "ここの色はエディタ上だけ。実機では壁紙の色になります。",
en: "These colors are editor-only; the phone uses its wallpaper colors.",
zh: "这些颜色仅用于编辑器,手机上会使用壁纸颜色。",
},
dynamicOffHint: {
ja: "オンにすると実機は壁紙の色を使い、ここの色は予備になります。",
en: "When on, the phone uses wallpaper colors and these are the fallback.",
zh: "开启后手机使用壁纸颜色,这里的颜色作为备用。",
},
closeBtn: { ja: "閉じる", en: "Close", zh: "关闭" },
screens: { ja: "画面を選ぶ", en: "Choose screen", zh: "选择屏幕" },
// layers
noLayers: { ja: "この画面には部品がありません", en: "Nothing on this screen yet", zh: "此屏幕还没有组件" },
showParts: { ja: "中の部品を表示", en: "Show the parts inside", zh: "显示组内组件" },
hideParts: { ja: "中の部品を隠す", en: "Hide the parts inside", zh: "隐藏组内组件" },
// prompt panel
brief: { ja: "このアプリの説明…", en: "What this app is…", zh: "这个应用的说明…" },
appName: { ja: "アプリの名前", en: "App name", zh: "应用名称" },
targetPlatform: { ja: "実装先", en: "Target", zh: "目标平台" },
targetAndroid: { ja: "Android のネイティブアプリとして作る", en: "Build as a native Android app", zh: "作为 Android 原生应用构建" },
targetWeb: { ja: "ブラウザで動く Web アプリとして作る", en: "Build as a web app that runs in the browser", zh: "作为在浏览器中运行的 Web 应用构建" },
copyPrompt: { ja: "プロンプトをコピー", en: "Copy prompt", zh: "复制提示词" },
// preview
back: { ja: "戻る", en: "Back", zh: "返回" },
close: { ja: "閉じる (Esc)", en: "Close (Esc)", zh: "关闭 (Esc)" },
// parts content
cancel: { ja: "キャンセル", en: "Cancel", zh: "取消" },
ok: { ja: "OK", en: "OK", zh: "确定" },
leading: { ja: "先頭", en: "Leading", zh: "前置" },
trailing: { ja: "末尾", en: "Trailing", zh: "后置" },
// frames
home: { ja: "ホーム", en: "Home", zh: "首页" },
screenN: { ja: "画面", en: "Screen", zh: "屏幕" },
copySuffix: { ja: " コピー", en: " copy", zh: " 副本" },
frameSize: { ja: "画面サイズ", en: "Screen size", zh: "屏幕尺寸" },
phoneFrame: { ja: "スマホ", en: "Phone", zh: "手机" },
desktopFrame: { ja: "デスクトップ", en: "Desktop", zh: "桌面" },
// mobile
mobileNote: { ja: "フル機能は PC のブラウザで使えます", en: "Full features on a desktop browser", zh: "完整功能请在电脑浏览器中使用" },
addButton: { ja: "ボタンを追加", en: "Add button", zh: "添加按钮" },
done: { ja: "完了", en: "Done", zh: "完成" },
theme: { ja: "テーマ", en: "Theme", zh: "主题" },
settings: { ja: "テーマと設定", en: "Theme and settings", zh: "主题与设置" },
// theme panels
shape: { ja: "シェイプ", en: "Shape", zh: "形状" },
typography: { ja: "タイポグラフィ", en: "Type", zh: "字体" },
motion: { ja: "モーション", en: "Motion", zh: "动效" },
brightness: { ja: "明るさ", en: "Brightness", zh: "明暗" },
light: { ja: "ライト", en: "Light", zh: "浅色" },
dark: { ja: "ダーク", en: "Dark", zh: "深色" },
contrast: { ja: "コントラスト", en: "Contrast", zh: "对比度" },
bothModes: { ja: "両対応", en: "Both", zh: "两者" },
contrastStandard: { ja: "標準", en: "Standard", zh: "标准" },
contrastMedium: { ja: "中", en: "Medium", zh: "中" },
contrastHigh: { ja: "高", en: "High", zh: "高" },
shapeScale: { ja: "角丸の度合い", en: "Corner roundness", zh: "圆角程度" },
shapeSquare: { ja: "スクエア", en: "Square", zh: "方形" },
shapeRounded: { ja: "標準", en: "Rounded", zh: "圆角" },
shapeFull: { ja: "フル", en: "Full", zh: "全圆" },
shapeHint: {
ja: "すべての部品の初期の角丸をまとめて変えます。部品ごとに入力した角丸はそのまま残ります。",
en: "Changes the default corners of every part at once. A radius you typed on a part stays as it is.",
zh: "统一改变所有组件的默认圆角。已为单个组件输入的圆角保持不变。",
},
fontFamily: { ja: "書体", en: "Typeface", zh: "字体" },
emphasized: { ja: "強調スタイル", en: "Emphasized", zh: "强调样式" },
emphasizedHint: {
ja: "見出しやラベルを M3 Expressive の太めのスタイルにします。",
en: "Headlines and labels use the heavier M3 Expressive styles.",
zh: "标题和标签使用 M3 Expressive 的加粗样式。",
},
motionScheme: { ja: "動きの種類", en: "Motion scheme", zh: "动效方案" },
motionStandard: { ja: "スタンダード", en: "Standard", zh: "标准" },
motionExpressive: { ja: "エクスプレッシブ", en: "Expressive", zh: "富有表现力" },
motionHint: {
ja: "エクスプレッシブは弾むスプリング。プレビューの画面遷移とプロンプトに反映されます。",
en: "Expressive is a bouncy spring. It drives the preview transitions and the prompt.",
zh: "富有表现力为弹性弹簧动效,作用于预览的屏幕过渡和提示词。",
},
tryIt: { ja: "タップして確認", en: "Tap to try", zh: "点击试试" },
// tidy
tidy: { ja: "整える", en: "Tidy", zh: "整理" },
tidyUndo: { ja: "整える前に戻す", en: "Undo tidy", zh: "撤销整理" },
tidyDone: { ja: "すでに整っています", en: "Already tidy", zh: "已经整齐" },
// screen description
description: { ja: "説明", en: "Description", zh: "说明" },
screenDescription: { ja: "この画面の目的", en: "What this screen is for", zh: "这个屏幕的用途" },
// ai
ai: { ja: "AI", en: "AI", zh: "AI" },
promptReset: { ja: "生成されたプロンプトに戻す", en: "Back to the generated prompt", zh: "恢复为生成的提示词" },
aiWriteShort: { ja: "AI で書く", en: "Write with AI", zh: "AI 撰写" },
aiWrite: { ja: "AI に書いてもらう", en: "Let the AI write it", zh: "让 AI 来写" },
aiSettings: { ja: "AI の設定", en: "AI settings", zh: "AI 设置" },
aiProvider: { ja: "プロバイダ", en: "Provider", zh: "服务商" },
aiBaseUrl: { ja: "ベース URL", en: "Base URL", zh: "基础 URL" },
aiModel: { ja: "モデル ID", en: "Model ID", zh: "模型 ID" },
aiKey: { ja: "API キー", en: "API key", zh: "API 密钥" },
aiGetKey: { ja: "キーを取得", en: "Get a key", zh: "获取密钥" },
aiKeyHint: {
ja: "キーはこのブラウザにだけ保存され、プロバイダへ直接送られます。",
en: "Stored only in this browser and sent straight to the provider.",
zh: "密钥只保存在此浏览器中,并直接发送给服务商。",
},
aiRestore: { ja: "AI の前と切り替える", en: "Switch between the AI rewrite and the original", zh: "在 AI 改写与原文之间切换" },
aiApplied: { ja: "適用しました", en: "Applied", zh: "已应用" },
aiSelectScreen: { ja: "先に画面を選んでください", en: "Select a screen first", zh: "请先选择一个屏幕" },
aiNoKey: { ja: "AI タブでキーを入れると使えます", en: "Add a key in the AI tab to use this", zh: "在 AI 标签页中填写密钥后即可使用" },
aiError: { ja: "AI の呼び出しに失敗しました", en: "The AI request failed", zh: "AI 请求失败" },
aiErrorRefusal: { ja: "モデルが回答を拒否しました", en: "The model declined to answer", zh: "模型拒绝回答" },
aiErrorJson: { ja: "モデルの返答を読み取れませんでした", en: "The model's reply could not be read", zh: "无法解析模型的回复" },
aiErrorLong: { ja: "返答が長すぎて途中で切れました。画面を減らして試してください", en: "The reply was cut short. Try fewer screens", zh: "回复过长被截断了。请减少屏幕数量再试" },
aiErrorModel: { ja: "モデル ID を入力してください", en: "Enter a model ID", zh: "请输入模型 ID" },
aiErrorInsecure: { ja: "ベース URL は https か localhost にしてください", en: "The base URL must use https or point at localhost", zh: "基础 URL 必须使用 https 或指向 localhost" },
aiErrorNetwork: {
ja: "接続できませんでした。URL、ネットワーク、CORS の設定を確認してください",
en: "Could not connect. Check the URL, the network and the server's CORS settings",
zh: "无法连接。请检查 URL、网络和服务器的 CORS 设置",
},
} as const satisfies Record<string, Str>;
export type UIKey = keyof typeof UI;
const KO: Record<UIKey, string> = {
frameSize: "화면 크기", phoneFrame: "휴대전화", desktopFrame: "데스크톱", columnWidth: "휴대전화 한 화면 너비", cornerLeft: "왼쪽 모서리", cornerRight: "오른쪽 모서리", cornersEach: "모서리별로 지정", cornerTl: "왼쪽 위", cornerTr: "오른쪽 위", cornerBl: "왼쪽 아래", cornerBr: "오른쪽 아래",
filled: "채움", tonal: "색조", elevated: "그림자", outlined: "윤곽선", standard: "표준", vibrant: "선명함",
parts: "부품", layers: "레이어", edit: "편집", prompt: "프롬프트", closePanel: "패널 닫기",
search: "검색", favorites: "즐겨찾기", addFavorite: "즐겨찾기에 추가", removeFavorite: "즐겨찾기에서 제거", clear: "지우기", language: "언어",
select: "선택 (V)", hand: "손 도구 (H / Space)", blank: "빈 캔버스", phone: "휴대전화 화면", addFrame: "화면 추가", preview: "미리보기 (P)",
zoomIn: "확대 (+)", zoomOut: "축소 (-)", fit: "전체 맞춤 (0)", undo: "실행 취소 (Ctrl+Z)", redo: "다시 실행 (Ctrl+Shift+Z)",
clearAll: "모두 지우기", clearAllTitle: "캔버스를 비울까요?", clearAllBody: "모든 화면과 부품을 삭제합니다. 실행 취소(Ctrl+Z)로 복원할 수 있습니다.",
screen: "화면", screenName: "화면 이름", name: "이름", background: "배경", export: "내보내기", project: "프로젝트",
saveProject: "프로젝트 저장", openProject: "프로젝트 열기", replaceProjectTitle: "이 프로젝트를 열까요?",
replaceProject: "현재 캔버스가 교체됩니다. 실행 취소(Ctrl+Z)로 되돌릴 수 있습니다.",
askAi: "AI에게 맡기기",
askAiHint: "만들고 싶은 것을 적고 'AI로 만들기'를 누르면 AI 탭의 키로 설계가 캔버스에 그려집니다. 대신 지시를 복사해 Claude Code 같은 AI 에이전트에 넘길 수도 있습니다. 답한 링크를 열거나 JSON을 파일로 저장해 '프로젝트 열기'로 불러오세요.",
askAiGenerate: "AI로 만들기", askAiGenerating: "만드는 중…",
askAiPasted: "AI 에이전트에 붙여 넣으세요", askAiTitle: "AI에게 설계 맡기기", askAiCopyTitle: "AI 에이전트용 지시", askAiGenerateTitle: "AI 탭의 키로 만들기", aiSetup: "AI 설정", aiSetupHint: "키를 입력하면 여기서 바로 만들 수 있습니다", aiSetupTitle: "AI 탭 열기",
draftKeep: "이 설계 채택", draftUndo: "이전 설계로 되돌리기",
askAiIdea: "만들고 싶은 것(예: 레시피를 저장하고 검색하는 앱)", askAiIdeaFallback: "(여기에 만들고 싶은 것을 적으세요)", askAiCopy: "지시 복사",
askAiText: "M3E Canvas 스케치를 만들어 주세요. 먼저 {url}을 읽고 그 절차대로 설계를 JSON으로 구성한 뒤, 공유 링크 형태로 답해 주세요. 코드를 실행할 수 없다면 설계 JSON을 코드 블록으로 답해 주세요(파일로 저장해 불러옵니다). 검증은 필요 없습니다.\n\n만들고 싶은 것: {idea}",
selectedTab: "선택 항목으로 지정",
shareLinkCopy: "링크 복사", shareLinkHint: "이 설계를 여는 링크(이미지 제외)",
invalidProject: "프로젝트 파일을 열 수 없습니다.", readOnlyTitle: "다른 탭에서 편집 중입니다",
readOnlyBody: "이 캔버스는 다른 탭에서 편집 중입니다. 해당 탭을 닫은 다음 이 페이지를 새로고침하세요.",
reload: "새로고침",
copied: "복사됨", saveImage: "이미지로 저장", saving: "저장 중…", previewFrom: "이 화면부터 미리보기",
duplicate: "복제", duplicateKey: "복제 (Ctrl+D)", delete: "삭제 (Delete)", deleteSelection: "선택 항목 삭제",
text: "텍스트", label: "레이블", bold: "굵게", action: "동작", supporting: "보조 텍스트", tabs: "항목", changeIcon: "아이콘 변경",
image: "이미지", pickImage: "이미지 선택", removeImage: "이미지 제거", imageUrl: "이미지 URL", imageArea: "이미지 영역", autoWidth: "글자 너비", icon: "아이콘", noIcon: "아이콘 없음", searchIcons: "아이콘 검색",
style: "스타일", state: "상태", selected: "선택됨", handle: "핸들(하단 시트)", listSwitch: "끝에 스위치", on: "켜짐", container: "컨테이너", wavy: "물결 모양", determinate: "확정형",
size: "크기", width: "너비", height: "높이", fontSize: "글자 크기", cornerRadius: "모서리 둥글기", cornerTop: "위쪽 모서리", cornerBottom: "아래쪽 모서리",
screenWidth: "화면 너비", contentWidth: "좌우 16dp 여백", halfWidth: "한 행의 절반(2열)", screenHeight: "화면 높이", halfHeight: "화면의 절반",
tapTo: "탭하여 이동", none: "없음", goBack: "뒤로", swipeTo: "스와이프하여 이동", toggle: "토글 버튼", toggleHint: "탭할 때 켜짐/꺼짐 전환",
thumbCheck: "켜졌을 때 체크 아이콘 표시", behavior: "동작", whenPressed: "눌렀을 때…", whatItDoes: "이 부품의 동작…", removeLink: "링크 제거",
group: "그룹", makeGroup: "그룹화", ungroup: "그룹 해제", selectedParts: "개 선택됨", groupHint: "겹침을 유지한 채 하나의 레이어처럼 함께 이동합니다",
iconBackground: "아이콘 배경", noBackground: "배경 없음", normalState: "기본", onState: "켜짐", onStateHint: "켜졌을 때의 텍스트, 아이콘, 스타일",
groupEditNote: "안쪽 부품을 편집하려면 그룹을 해제하세요", openPanel: "패널 열기", colors: "색상", templates: "팔레트", customColor: "사용자 지정",
seedColor: "기준 색상", seedHint: "색상 하나로 전체 Material 3 색상 구성을 만듭니다. 세부 조정에서 개별 색상도 바꿀 수 있습니다.",
useThis: "이 색상 사용", fineTune: "세부 조정", dynamicColor: "동적 색상",
dynamicOnHint: "여기 표시된 색상은 편집기 전용입니다. 실제 기기에서는 배경화면 색상을 사용합니다.",
dynamicOffHint: "켜면 실제 기기는 배경화면 색상을 사용하고 여기의 색상은 대체 색상이 됩니다.", closeBtn: "닫기", screens: "화면 선택",
noLayers: "이 화면에는 아직 부품이 없습니다", showParts: "안의 부품 표시", hideParts: "안의 부품 숨기기",
brief: "이 앱에 대한 설명…", appName: "앱 이름", targetPlatform: "구현 대상", targetAndroid: "Android 네이티브 앱으로 만들기",
targetWeb: "브라우저에서 실행되는 웹 앱으로 만들기", copyPrompt: "프롬프트 복사", back: "뒤로", close: "닫기 (Esc)", cancel: "취소", ok: "확인",
leading: "앞쪽", trailing: "뒤쪽", home: "홈", screenN: "화면", copySuffix: " 복사본", mobileNote: "전체 기능은 데스크톱 브라우저에서 사용할 수 있습니다",
addButton: "버튼 추가", done: "완료", theme: "테마", settings: "테마 및 설정", shape: "모양", typography: "글꼴", motion: "모션",
brightness: "밝기", light: "라이트", dark: "다크", contrast: "대비", bothModes: "둘 다", contrastStandard: "표준", contrastMedium: "중간", contrastHigh: "높음",
shapeScale: "모서리 둥글기", shapeSquare: "사각형", shapeRounded: "둥근형", shapeFull: "완전 둥근형",
shapeHint: "모든 부품의 기본 모서리를 한 번에 바꿉니다. 부품에 직접 입력한 반경은 유지됩니다.", fontFamily: "글꼴", emphasized: "강조 스타일",
emphasizedHint: "제목과 레이블에 더 굵은 M3 Expressive 스타일을 사용합니다.", motionScheme: "모션 방식", motionStandard: "표준", motionExpressive: "익스프레시브",
motionHint: "익스프레시브는 통통 튀는 스프링 효과입니다. 미리보기 화면 전환과 프롬프트에 반영됩니다.", tryIt: "탭하여 확인",
tidy: "정리", tidyUndo: "정리 실행 취소", tidyDone: "이미 정돈되어 있습니다", description: "설명", screenDescription: "이 화면의 용도",
ai: "AI", promptReset: "생성된 프롬프트로 되돌리기", aiWriteShort: "AI로 작성", aiWrite: "AI에게 작성 맡기기", aiSettings: "AI 설정",
aiProvider: "제공업체", aiBaseUrl: "기본 URL", aiModel: "모델 ID", aiKey: "API 키", aiGetKey: "키 받기",
aiKeyHint: "키는 이 브라우저에만 저장되며 제공업체로 직접 전송됩니다.", aiRestore: "AI 수정본과 원본 전환", aiApplied: "적용됨",
aiSelectScreen: "먼저 화면을 선택하세요", aiNoKey: "AI 탭에 키를 입력하면 사용할 수 있습니다", aiError: "AI 요청에 실패했습니다",
aiErrorRefusal: "모델이 답변을 거부했습니다", aiErrorJson: "모델의 응답을 읽을 수 없습니다", aiErrorLong: "답변이 너무 길어 중간에 잘렸습니다. 화면 수를 줄여 다시 시도하세요", aiErrorModel: "모델 ID를 입력하세요",
aiErrorInsecure: "기본 URL은 https를 사용하거나 localhost를 가리켜야 합니다", aiErrorNetwork: "연결할 수 없습니다. URL, 네트워크 및 서버의 CORS 설정을 확인하세요",
};
export const t = (key: UIKey, lang: Lang = current): string => (lang === "ko" ? KO[key] : UI[key][lang]);
/* ---- part defaults and nouns ---- */
export const KIND_TEXT: Record<
Lang,
Record<string, { noun: string; label?: string; supporting?: string }>
> = {
ja: {
box: { noun: "ボックス" },
button: { noun: "ボタン", label: "ボタン" },
iconButton: { noun: "アイコンボタン" },
fab: { noun: "FAB(フローティングボタン)" },
extendedFab: { noun: "拡張 FAB", label: "作成" },
chip: { noun: "チップ", label: "チップ" },
topAppBar: { noun: "トップアプリバー", label: "タイトル" },
bottomNav: { noun: "ナビゲーションバー" },
navRail: { noun: "ナビゲーションレール" },
searchBar: { noun: "検索バー", label: "検索" },
card: { noun: "カード", label: "カードの見出し", supporting: "補足テキストがここに入ります。" },
listItem: { noun: "リスト項目", label: "リスト項目", supporting: "サブテキスト" },
dialog: { noun: "ダイアログ", label: "確認", supporting: "この操作を実行しますか?" },
snackbar: { noun: "スナックバー", label: "保存しました", supporting: "元に戻す" },
textField: { noun: "テキスト入力", label: "ラベル" },
switch: { noun: "スイッチ", label: "通知" },
checkbox: { noun: "チェックボックス", label: "同意する" },
slider: { noun: "スライダー" },
text: { noun: "テキスト", label: "見出し" },
image: { noun: "画像" },
divider: { noun: "区切り線" },
loadingIndicator: { noun: "ローディングインジケータ" },
linearProgress: { noun: "リニアプログレス" },
circularProgress: { noun: "サーキュラープログレス" },
splitButton: { noun: "スプリットボタン", label: "送信" },
fabMenu: { noun: "FAB メニュー" },
toolbar: { noun: "ツールバー" },
tabs: { noun: "タブ" },
radio: { noun: "ラジオボタン", label: "選択肢" },
badge: { noun: "バッジ", label: "3" },
},
en: {
box: { noun: "box" },
button: { noun: "button", label: "Button" },
iconButton: { noun: "icon button" },
fab: { noun: "FAB" },
extendedFab: { noun: "extended FAB", label: "Create" },
chip: { noun: "chip", label: "Chip" },
topAppBar: { noun: "top app bar", label: "Title" },
bottomNav: { noun: "navigation bar" },
navRail: { noun: "navigation rail" },
searchBar: { noun: "search bar", label: "Search" },
card: { noun: "card", label: "Card headline", supporting: "Supporting text goes here." },
listItem: { noun: "list item", label: "List item", supporting: "Supporting text" },
dialog: { noun: "dialog", label: "Confirm", supporting: "Do you want to continue?" },
snackbar: { noun: "snackbar", label: "Saved", supporting: "Undo" },
textField: { noun: "text field", label: "Label" },
switch: { noun: "switch", label: "Notifications" },
checkbox: { noun: "checkbox", label: "I agree" },
slider: { noun: "slider" },
text: { noun: "text", label: "Headline" },
image: { noun: "image" },
divider: { noun: "divider" },
loadingIndicator: { noun: "loading indicator" },
linearProgress: { noun: "linear progress indicator" },
circularProgress: { noun: "circular progress indicator" },
splitButton: { noun: "split button", label: "Send" },
fabMenu: { noun: "FAB menu" },
toolbar: { noun: "toolbar" },
tabs: { noun: "tabs" },
radio: { noun: "radio button", label: "Option" },
badge: { noun: "badge", label: "3" },
},
zh: {
box: { noun: "容器框" },
button: { noun: "按钮", label: "按钮" },
iconButton: { noun: "图标按钮" },
fab: { noun: "FAB(悬浮按钮)" },
extendedFab: { noun: "扩展 FAB", label: "新建" },
chip: { noun: "标签片", label: "标签" },
topAppBar: { noun: "顶部应用栏", label: "标题" },
bottomNav: { noun: "导航栏" },
navRail: { noun: "侧边导航栏" },
searchBar: { noun: "搜索栏", label: "搜索" },
card: { noun: "卡片", label: "卡片标题", supporting: "这里是辅助说明文字。" },
listItem: { noun: "列表项", label: "列表项", supporting: "辅助文本" },
dialog: { noun: "对话框", label: "确认", supporting: "要执行此操作吗?" },
snackbar: { noun: "消息条", label: "已保存", supporting: "撤销" },
textField: { noun: "文本输入框", label: "标签" },
switch: { noun: "开关", label: "通知" },
checkbox: { noun: "复选框", label: "我同意" },
slider: { noun: "滑块" },
text: { noun: "文本", label: "标题" },
image: { noun: "图片" },
divider: { noun: "分割线" },
loadingIndicator: { noun: "加载指示器" },
linearProgress: { noun: "线性进度条" },
circularProgress: { noun: "圆形进度条" },
splitButton: { noun: "拆分按钮", label: "发送" },
fabMenu: { noun: "FAB 菜单" },
toolbar: { noun: "工具栏" },
tabs: { noun: "标签页" },
radio: { noun: "单选按钮", label: "选项" },
badge: { noun: "徽标", label: "3" },
},
ko: {
box: { noun: "상자" },
button: { noun: "버튼", label: "버튼" },
iconButton: { noun: "아이콘 버튼" },
fab: { noun: "FAB" },
extendedFab: { noun: "확장 FAB", label: "만들기" },
chip: { noun: "칩", label: "칩" },
topAppBar: { noun: "상단 앱 바", label: "제목" },
bottomNav: { noun: "내비게이션 바" },
navRail: { noun: "내비게이션 레일" },
searchBar: { noun: "검색창", label: "검색" },
card: { noun: "카드", label: "카드 제목", supporting: "보조 텍스트가 여기에 표시됩니다." },
listItem: { noun: "목록 항목", label: "목록 항목", supporting: "보조 텍스트" },
dialog: { noun: "대화상자", label: "확인", supporting: "계속하시겠습니까?" },
snackbar: { noun: "스낵바", label: "저장됨", supporting: "실행 취소" },
textField: { noun: "텍스트 입력란", label: "레이블" },
switch: { noun: "스위치", label: "알림" },
checkbox: { noun: "체크박스", label: "동의합니다" },
slider: { noun: "슬라이더" },
text: { noun: "텍스트", label: "제목" },
image: { noun: "이미지" },
divider: { noun: "구분선" },
loadingIndicator: { noun: "로딩 표시기" },
linearProgress: { noun: "선형 진행 표시기" },
circularProgress: { noun: "원형 진행 표시기" },
splitButton: { noun: "분할 버튼", label: "보내기" },
fabMenu: { noun: "FAB 메뉴" },
toolbar: { noun: "도구 모음" },
tabs: { noun: "탭" },
radio: { noun: "라디오 버튼", label: "옵션" },
badge: { noun: "배지", label: "3" },
},
};
/** default labels of a tab row */
export const TAB_LABELS: Record<Lang, string[]> = {
ja: ["おすすめ", "フォロー中", "人気", "新着", "保存済み"],
en: ["For you", "Following", "Trending", "New", "Saved"],
zh: ["推荐", "关注", "热门", "最新", "已保存"],
ko: ["추천", "팔로잉", "인기", "새 항목", "저장됨"],
};
/** default entries of a FAB menu */
export const FAB_MENU_TABS: Record<Lang, { icon: string; label: string }[]> = {
ja: [
{ icon: "edit", label: "メモ" },
{ icon: "photo_camera", label: "写真" },
{ icon: "mic", label: "音声" },
{ icon: "attach_file", label: "ファイル" },
{ icon: "event", label: "予定" },
],
en: [
{ icon: "edit", label: "Note" },
{ icon: "photo_camera", label: "Photo" },
{ icon: "mic", label: "Audio" },
{ icon: "attach_file", label: "File" },
{ icon: "event", label: "Event" },
],
zh: [
{ icon: "edit", label: "笔记" },
{ icon: "photo_camera", label: "照片" },
{ icon: "mic", label: "语音" },
{ icon: "attach_file", label: "文件" },
{ icon: "event", label: "日程" },
],
ko: [
{ icon: "edit", label: "메모" },
{ icon: "photo_camera", label: "사진" },
{ icon: "mic", label: "오디오" },
{ icon: "attach_file", label: "파일" },
{ icon: "event", label: "일정" },
],
};
export const NAV_TABS: Record<Lang, { icon: string; label: string }[]> = {
ja: [
{ icon: "home", label: "ホーム" },
{ icon: "search", label: "検索" },
{ icon: "favorite", label: "保存" },
{ icon: "settings", label: "設定" },
],
en: [
{ icon: "home", label: "Home" },
{ icon: "search", label: "Search" },
{ icon: "favorite", label: "Saved" },
{ icon: "settings", label: "Settings" },
],
zh: [
{ icon: "home", label: "首页" },
{ icon: "search", label: "搜索" },
{ icon: "favorite", label: "收藏" },
{ icon: "settings", label: "设置" },
],
ko: [
{ icon: "home", label: "홈" },
{ icon: "search", label: "검색" },
{ icon: "favorite", label: "저장됨" },
{ icon: "settings", label: "설정" },
],
};
export const TRANSITION_TEXT: Record<Lang, Record<string, string>> = {
ja: {
slide: "右からのスライド",
slideLeft: "左からのスライド",
slideUp: "下からのスライド",
slideDown: "上からのスライド",
fade: "フェード",
expand: "拡大",
none: "アニメーションなし",
},
en: {
slide: "a slide in from the right",
slideLeft: "a slide in from the left",
slideUp: "a slide up from the bottom",
slideDown: "a slide down from the top",
fade: "a fade",
expand: "an expand",
none: "no animation",
},
zh: {
slide: "从右侧滑入",
slideLeft: "从左侧滑入",
slideUp: "从底部滑入",
slideDown: "从顶部滑入",
fade: "淡入",
expand: "放大",
none: "无动画",
},
ko: {
slide: "오른쪽에서 슬라이드",
slideLeft: "왼쪽에서 슬라이드",
slideUp: "아래에서 슬라이드",
slideDown: "위에서 슬라이드",
fade: "페이드",
expand: "확대",
none: "애니메이션 없음",
},
};
export const SWIPE_TEXT: Record<Lang, Record<string, string>> = {
ja: { left: "左へスワイプ", right: "右へスワイプ", up: "上へスワイプ", down: "下へスワイプ" },
en: { left: "swiping left", right: "swiping right", up: "swiping up", down: "swiping down" },
zh: { left: "向左滑动", right: "向右滑动", up: "向上滑动", down: "向下滑动" },
ko: { left: "왼쪽으로 스와이프", right: "오른쪽으로 스와이프", up: "위로 스와이프", down: "아래로 스와이프" },
};
+82
View File
@@ -0,0 +1,82 @@
import { Doc, KIND_ORDER, Kind, VARIANTS, isPlatform } from "./tokens";
/* A project file is the Doc as JSON, nothing more. Reading one back only checks
* the shape the editor relies on; the same migrations that run on a saved
* document then bring an older file up to date. */
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const KINDS = new Set<string>(KIND_ORDER);
const validTabs = (tabs: unknown) =>
tabs === undefined || (Array.isArray(tabs) && tabs.every((tab) => isRecord(tab) && typeof tab.label === "string" && (typeof tab.icon === "string" || tab.icon === null || tab.icon === undefined)));
const validCorners = (c: unknown) => c === undefined || (isRecord(c) && ["tl", "tr", "bl", "br"].every((k) => Number.isFinite(c[k])));
const validItem = (item: unknown) =>
isRecord(item) &&
validCorners(item.corners) &&
typeof item.id === "string" &&
typeof item.kind === "string" &&
KINDS.has(item.kind as Kind) &&
typeof item.label === "string" &&
(typeof item.icon === "string" || item.icon === null) &&
VARIANTS.some((variant) => variant.key === item.variant) &&
(item.supporting === undefined || typeof item.supporting === "string") &&
(item.selected === undefined || Number.isFinite(item.selected)) &&
(item.note === undefined || typeof item.note === "string") &&
validTabs(item.tabs);
const validGroup = (group: unknown) =>
isRecord(group) &&
typeof group.id === "string" &&
Number.isFinite(group.x) &&
Number.isFinite(group.y) &&
(group.axis === "x" || group.axis === "y") &&
Array.isArray(group.items) &&
group.items.length > 0 &&
group.items.every(validItem);
const validFrame = (frame: unknown) =>
isRecord(frame) &&
typeof frame.id === "string" &&
typeof frame.name === "string" &&
Number.isFinite(frame.x) &&
Number.isFinite(frame.y) &&
(frame.w === undefined || (Number.isFinite(frame.w) && (frame.w as number) > 0)) &&
(frame.h === undefined || (Number.isFinite(frame.h) && (frame.h as number) > 0)) &&
(frame.note === undefined || typeof frame.note === "string");
/** whether a parsed file has the shape of a document the editor can open */
export const isProject = (value: unknown): value is Doc =>
isRecord(value) && Array.isArray(value.groups) && Array.isArray(value.frames) && value.groups.every(validGroup) && value.frames.every(validFrame) && (value.platform === undefined || isPlatform(value.platform));
/** the file name a project is saved under: m3e-canvas, followed by the app's name when it has one */
export const projectFileName = (doc: Doc) => {
const name = doc.title
.trim()
.replace(/[\\/:*?"<>|]+/g, " ")
.replace(/\s+/g, " ")
.trim();
return name ? `m3e-canvas ${name}.json` : "m3e-canvas.json";
};
/** hands the document to the browser as a JSON download */
export function saveProject(doc: Doc) {
const url = URL.createObjectURL(new Blob([JSON.stringify(doc, null, 2)], { type: "application/json" }));
const a = document.createElement("a");
a.href = url;
a.download = projectFileName(doc);
a.click();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
/** reads a chosen file back into a document, or null when it is not one */
export async function readProject(file: File): Promise<Doc | null> {
try {
const next: unknown = JSON.parse(await file.text());
return isProject(next) ? next : null;
} catch {
return null;
}
}
+111
View File
@@ -0,0 +1,111 @@
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」");
});
});
+1372
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -0,0 +1,99 @@
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);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* Material 3 Expressive loading-indicator shapes.
*
* SVG path data comes from the official Material Design shape assets and the
* morph/rotation model mirrors material-components-android's
* LoadingIndicatorAnimatorDelegate (Apache-2.0, see NOTICE).
* Sequence: SOFT_BURST → COOKIE_9 → PENTAGON → PILL → SUNNY → COOKIE_4 → OVAL
*/
export type Point = [x: number, y: number];
export const SHAPE_COUNT = 7;
const SAMPLES_PER_CURVE = 14;
const POINTS_PER_SHAPE = 180;
type ShapeDef = { viewBox: number; d: string };
const SVG_DEFS: (ShapeDef | null)[] = [
{
viewBox: 144,
d: "M65.3162 3.5567C68.3922 -1.18556 75.6078 -1.18557 78.6837 3.55669L86.0569 14.9241C88.0791 18.0418 92.1588 19.3094 95.7112 17.9237L108.664 12.8715C114.067 10.7638 119.905 14.8195 119.478 20.3849L118.456 33.7255C118.175 37.3845 120.697 40.7029 124.422 41.5786L138.007 44.7714C143.674 46.1033 145.903 52.6655 142.137 56.9283L133.11 67.1465C130.634 69.9491 130.634 74.0509 133.11 76.8535L142.137 87.0716C145.903 91.3345 143.674 97.8967 138.007 99.2286L124.422 102.421C120.697 103.297 118.175 106.616 118.456 110.274L119.478 123.615C119.905 129.181 114.067 133.236 108.664 131.129L95.7112 126.076C92.1588 124.691 88.0791 125.958 86.0569 129.076L78.6838 140.443C75.6078 145.186 68.3922 145.186 65.3162 140.443L57.9431 129.076C55.9208 125.958 51.8412 124.691 48.2888 126.076L35.3365 131.129C29.933 133.236 24.0954 129.181 24.5219 123.615L25.5442 110.274C25.8246 106.616 23.3033 103.297 19.5775 102.421L5.99339 99.2286C0.326335 97.8967 -1.90342 91.3345 1.86259 87.0717L10.8899 76.8535C13.3658 74.0509 13.3658 69.9491 10.8899 67.1465L1.8626 56.9283C-1.90341 52.6655 0.326326 46.1033 5.99338 44.7714L19.5775 41.5786C23.3033 40.7029 25.8246 37.3845 25.5442 33.7255L24.5219 20.3849C24.0954 14.8195 29.933 10.7638 35.3364 12.8715L48.2888 17.9237C51.8412 19.3094 55.9208 18.0418 57.9431 14.9241L65.3162 3.5567Z",
},
{
viewBox: 144,
d: "M56.3679 6.02002C57.1442 5.3783 57.5324 5.05744 57.8867 4.78656C66.2354 -1.59552 77.7646 -1.59552 86.1133 4.78656C86.4676 5.05744 86.8558 5.3783 87.6321 6.02002C87.9786 6.30648 88.1519 6.44971 88.3233 6.58606C92.2522 9.71203 97.0693 11.4835 102.068 11.6405C102.286 11.6473 102.51 11.6501 102.957 11.6558C103.96 11.6684 104.462 11.6747 104.906 11.6973C115.361 12.2303 124.193 19.7179 126.528 30.0289C126.627 30.4666 126.721 30.9644 126.907 31.9602C126.99 32.4047 127.032 32.627 127.076 32.8427C128.097 37.789 130.661 42.2744 134.39 45.6409C134.552 45.7878 134.722 45.9353 135.062 46.2304C135.822 46.8914 136.202 47.2219 136.528 47.5275C144.198 54.7262 146.2 66.1979 141.429 75.6132C141.227 76.0128 140.981 76.4547 140.49 77.3386C140.271 77.7332 140.162 77.9304 140.059 78.1246C137.694 82.5768 136.804 87.6775 137.519 92.6782C137.55 92.8964 137.586 93.1196 137.658 93.5661C137.82 94.5662 137.901 95.0663 137.956 95.5117C139.252 106.008 133.488 116.096 123.843 120.21C123.434 120.385 122.965 120.564 122.026 120.922C121.608 121.082 121.398 121.162 121.196 121.244C116.552 123.119 112.625 126.448 109.991 130.743C109.876 130.93 109.762 131.125 109.533 131.514C109.021 132.385 108.765 132.821 108.523 133.198C102.839 142.08 92.0048 146.064 81.9992 142.952C81.5745 142.82 81.1011 142.652 80.1544 142.318C79.7318 142.168 79.5205 142.094 79.3133 142.025C74.5631 140.445 69.4369 140.445 64.6867 142.025C64.4795 142.094 64.2682 142.168 63.8456 142.318C62.8989 142.652 62.4255 142.82 62.0008 142.952C51.9952 146.064 41.1613 142.08 35.4766 133.198C35.2353 132.821 34.9791 132.385 34.4669 131.514C34.2382 131.125 34.1239 130.93 34.009 130.743C31.3752 126.448 27.4482 123.119 22.8044 121.244C22.6018 121.162 22.3924 121.082 21.9736 120.922C21.0354 120.564 20.5663 120.385 20.1569 120.21C10.5122 116.096 4.74763 106.008 6.04367 95.5117C6.09868 95.0663 6.17963 94.5662 6.34151 93.5661C6.41377 93.1196 6.4499 92.8964 6.48109 92.6783C7.19603 87.6775 6.30587 82.5768 3.94121 78.1246C3.83807 77.9304 3.72855 77.7332 3.50951 77.3386C3.01883 76.4547 2.77349 76.0128 2.57099 75.6132C-2.19995 66.1979 -0.197931 54.7262 7.47248 47.5275C7.79804 47.2219 8.17819 46.8914 8.93848 46.2304C9.27787 45.9353 9.44757 45.7878 9.61023 45.6409C13.3394 42.2744 15.9025 37.789 16.9235 32.8427C16.9681 32.627 17.0097 32.4047 17.0929 31.9602C17.2793 30.9644 17.3725 30.4666 17.4717 30.0289C19.8069 19.7179 28.6387 12.2303 39.0944 11.6973C39.5382 11.6747 40.0397 11.6684 41.0426 11.6558C41.4903 11.6501 41.7142 11.6473 41.9322 11.6405C46.9307 11.4835 51.7478 9.71203 55.6767 6.58606C55.8481 6.44971 56.0214 6.30648 56.3679 6.02002Z",
},
{
viewBox: 144,
d: "M49.3332 10.9681C56.3577 5.53061 59.8699 2.81189 63.6224 1.46315C69.0501 -0.487717 74.9499 -0.487717 80.3776 1.46315C84.1301 2.81189 87.6423 5.53062 94.6668 10.9681L110.03 22.8606L125.386 34.0038C132.67 39.2902 136.313 41.9334 138.747 45.2576C142.27 50.0661 144.119 55.9761 143.994 62.0207C143.907 66.1996 142.46 70.5678 139.564 79.3044L133.535 97.4958L127.969 116.136C125.358 124.884 124.052 129.258 121.769 132.66C118.466 137.581 113.667 141.201 108.144 142.936C104.327 144.135 99.9259 144.065 91.1241 143.926L72 143.623L52.8759 143.926C44.0741 144.065 39.6732 144.135 35.8555 142.936C30.3334 141.201 25.5338 137.581 22.2314 132.66C19.9483 129.258 18.6425 124.884 16.0307 116.136L10.4655 97.4958L4.43609 79.3044C1.54044 70.5678 0.0926215 66.1996 0.00597479 62.0207C-0.119358 55.9761 1.73035 50.0661 5.2525 45.2576C7.68747 41.9334 11.3298 39.2902 18.6143 34.0038L33.9696 22.8606L49.3332 10.9681Z",
},
{
viewBox: 144,
d: "M40.4355 21.4968C63.0979 -1.16559 99.8408 -1.16559 122.503 21.4968C145.166 44.1592 145.166 80.9021 122.503 103.565L103.565 122.503C80.9021 145.166 44.1592 145.166 21.4968 122.503C-1.16559 99.8408 -1.1656 63.0979 21.4968 40.4355L40.4355 21.4968Z",
},
{
viewBox: 153,
d: "M117.835 18.5569C122.594 18.8803 124.973 19.0419 126.896 19.883C129.679 21.0999 131.9 23.3213 133.117 26.1039C133.958 28.027 134.12 30.4062 134.443 35.1647L135.181 46.0237C135.312 47.9482 135.377 48.9105 135.586 49.8297C135.889 51.1579 136.414 52.4254 137.139 53.5784C137.641 54.3763 138.275 55.1029 139.544 56.5563L146.7 64.7565C149.837 68.3499 151.405 70.1466 152.17 72.1011C153.277 74.9292 153.277 78.0708 152.17 80.8989C151.405 82.8534 149.837 84.6501 146.7 88.2435L139.544 96.4437C138.275 97.8971 137.641 98.6237 137.139 99.4217C136.414 100.575 135.889 101.842 135.586 103.17C135.377 104.09 135.312 105.052 135.181 106.976L134.443 117.835C134.12 122.594 133.958 124.973 133.117 126.896C131.9 129.679 129.679 131.9 126.896 133.117C124.973 133.958 122.594 134.12 117.835 134.443L106.976 135.181C105.052 135.312 104.09 135.377 103.17 135.586C101.842 135.889 100.575 136.414 99.4217 137.139C98.6237 137.641 97.8971 138.275 96.4437 139.544L88.2435 146.7C84.6501 149.837 82.8534 151.405 80.8989 152.17C78.0708 153.277 74.9292 153.277 72.1011 152.17C70.1466 151.405 68.3499 149.837 64.7565 146.7L56.5563 139.544C55.1029 138.275 54.3763 137.641 53.5784 137.139C52.4254 136.414 51.1579 135.889 49.8297 135.586C48.9105 135.377 47.9482 135.312 46.0237 135.181L35.1647 134.443C30.4062 134.12 28.027 133.958 26.1039 133.117C23.3213 131.9 21.0999 129.679 19.883 126.896C19.0419 124.973 18.8803 122.594 18.5569 117.835L17.819 106.976C17.6882 105.052 17.6228 104.09 17.4136 103.17C17.1113 101.842 16.5863 100.575 15.8608 99.4217C15.3588 98.6237 14.7246 97.8971 13.4562 96.4437L6.29956 88.2435C3.16348 84.6501 1.59544 82.8534 0.830322 80.8989C-0.276774 78.0708 -0.276774 74.9292 0.830323 72.1011C1.59544 70.1466 3.16348 68.3499 6.29956 64.7565L13.4562 56.5563C14.7246 55.1029 15.3588 54.3763 15.8608 53.5784C16.5863 52.4254 17.1113 51.1579 17.4136 49.8297C17.6228 48.9105 17.6882 47.9482 17.819 46.0237L18.5569 35.1647C18.8803 30.4062 19.0419 28.027 19.883 26.1039C21.0999 23.3213 23.3213 21.0999 26.1039 19.883C28.027 19.0419 30.4062 18.8803 35.1647 18.5569L46.0237 17.819C47.9482 17.6882 48.9105 17.6228 49.8297 17.4136C51.1579 17.1113 52.4254 16.5863 53.5784 15.8608C54.3763 15.3588 55.1029 14.7246 56.5563 13.4562L64.7565 6.29957C68.3499 3.16348 70.1466 1.59544 72.1011 0.830323C74.9292 -0.276774 78.0708 -0.276774 80.8989 0.830323C82.8534 1.59544 84.6501 3.16348 88.2435 6.29957L96.4437 13.4562C97.8971 14.7246 98.6237 15.3588 99.4216 15.8608C100.575 16.5863 101.842 17.1113 103.17 17.4136C104.09 17.6228 105.052 17.6882 106.976 17.819L117.835 18.5569Z",
},
{
viewBox: 144,
d: "M89.4282 11.7931C116.492 0.0387955 143.961 27.5077 132.207 54.5718L130.263 59.0465C126.675 67.3094 126.675 76.6907 130.263 84.9535L132.207 89.4282C143.961 116.492 116.492 143.961 89.4282 132.207L84.9535 130.263C76.6907 126.675 67.3093 126.675 59.0465 130.263L54.5718 132.207C27.5077 143.961 0.0387983 116.492 11.7931 89.4282L13.7366 84.9535C17.3253 76.6907 17.3252 67.3093 13.7366 59.0465L11.7931 54.5718C0.0387955 27.5077 27.5077 0.0387993 54.5718 11.7931L59.0465 13.7366C67.3094 17.3252 76.6907 17.3252 84.9535 13.7366L89.4282 11.7931Z",
},
null,
];
type PathCmd = { cmd: string; args: number[] };
function parseSVGPath(d: string): PathCmd[] {
const cmds: PathCmd[] = [];
const re = /([MCLZmclz])([^MCLZmclz]*)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(d)) !== null) {
const nums = m[2].trim().match(/-?\d+\.?\d*(?:e[+-]?\d+)?/g);
cmds.push({ cmd: m[1], args: nums ? nums.map(Number) : [] });
}
return cmds;
}
function sampleCubic(p0: Point, p1: Point, p2: Point, p3: Point, steps: number): Point[] {
const out: Point[] = [];
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const u = 1 - t;
out.push([
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
]);
}
return out;
}
function svgPathToPoints(d: string): Point[] {
const pts: Point[] = [];
let cx = 0;
let cy = 0;
for (const { cmd, args } of parseSVGPath(d)) {
if (cmd === "M") {
cx = args[0];
cy = args[1];
pts.push([cx, cy]);
} else if (cmd === "L") {
for (let i = 0; i < args.length; i += 2) {
cx = args[i];
cy = args[i + 1];
pts.push([cx, cy]);
}
} else if (cmd === "C") {
for (let i = 0; i < args.length; i += 6) {
const p3: Point = [args[i + 4], args[i + 5]];
pts.push(
...sampleCubic([cx, cy], [args[i], args[i + 1]], [args[i + 2], args[i + 3]], p3, SAMPLES_PER_CURVE),
);
cx = p3[0];
cy = p3[1];
}
}
}
return pts;
}
function normalize(pts: Point[], viewBox: number): Point[] {
const h = viewBox / 2;
const c: Point[] = pts.map(([x, y]) => [(x - h) / h, (y - h) / h]);
let x0 = Infinity;
let x1 = -Infinity;
let y0 = Infinity;
let y1 = -Infinity;
for (const [x, y] of c) {
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
const s = Math.min(2 / (x1 - x0), 2 / (y1 - y0));
const ox = (x0 + x1) / 2;
const oy = (y0 + y1) / 2;
return c.map(([x, y]) => [(x - ox) * s, (y - oy) * s]);
}
function resample(pts: Point[], n: number): Point[] {
const all: Point[] = [...pts, pts[0]];
const arc = [0];
for (let i = 1; i < all.length; i++) {
const dx = all[i][0] - all[i - 1][0];
const dy = all[i][1] - all[i - 1][1];
arc.push(arc[i - 1] + Math.hypot(dx, dy));
}
const total = arc[arc.length - 1];
const out: Point[] = [];
for (let i = 0; i < n; i++) {
const target = (i / n) * total;
let j = 1;
while (j < arc.length - 1 && arc[j] < target) j++;
const t = arc[j] > arc[j - 1] ? (target - arc[j - 1]) / (arc[j] - arc[j - 1]) : 0;
out.push([
all[j - 1][0] + t * (all[j][0] - all[j - 1][0]),
all[j - 1][1] + t * (all[j][1] - all[j - 1][1]),
]);
}
return out;
}
function generateOval(n: number): Point[] {
const pts: Point[] = [];
for (let i = 0; i < n; i++) {
const t = (i / n) * 2 * Math.PI;
pts.push([Math.cos(t), 0.74 * Math.sin(t)]);
}
return pts;
}
let cache: Point[][] | null = null;
export function getShapes(): Point[][] {
if (cache) return cache;
cache = SVG_DEFS.map((def) =>
def === null
? generateOval(POINTS_PER_SHAPE)
: resample(normalize(svgPathToPoints(def.d), def.viewBox), POINTS_PER_SHAPE),
);
return cache;
}
export function morphedShape(fraction: number): Point[] {
const shapes = getShapes();
const idx = Math.floor(fraction);
const from = ((idx % SHAPE_COUNT) + SHAPE_COUNT) % SHAPE_COUNT;
const to = (from + 1) % SHAPE_COUNT;
const t = Math.max(0, Math.min(1, fraction - idx));
const a = shapes[from];
const b = shapes[to];
const out: Point[] = new Array(a.length);
for (let i = 0; i < a.length; i++) {
out[i] = [a[i][0] + (b[i][0] - a[i][0]) * t, a[i][1] + (b[i][1] - a[i][1]) * t];
}
return out;
}
/** Spring matching Android's SpringForce (mass = 1). */
export class Spring {
private k: number;
private c: number;
pos = 0;
vel = 0;
target = 0;
constructor(stiffness: number, dampingRatio: number) {
this.k = stiffness;
this.c = dampingRatio * 2 * Math.sqrt(stiffness);
}
step(dt: number) {
const sub = dt / 12;
for (let i = 0; i < 12; i++) {
const accel = -this.k * (this.pos - this.target) - this.c * this.vel;
this.vel += accel * sub;
this.pos += this.vel * sub;
}
}
}
export const DURATION_PER_SHAPE_MS = 650;
const CONSTANT_ROTATION_DEG = 50;
const EXTRA_ROTATION_DEG = 90;
/** Mirrors LoadingIndicatorAnimatorDelegate: 650 ms per shape, spring-driven morph, dual rotation. */
export class LoadingAnimator {
private spring = new Spring(200, 0.6);
private morphTarget = 1;
private elapsed = 0;
private lastTs = 0;
private prevCycle = 0;
rotation = 0;
morph = 0;
constructor() {
this.spring.target = 1;
}
update(ts: number) {
if (this.lastTs === 0) this.lastTs = ts;
const dt = Math.min((ts - this.lastTs) / 1000, 0.1);
this.lastTs = ts;
if (dt <= 0) return;
this.elapsed += dt * 1000;
const cycle = Math.floor(this.elapsed / DURATION_PER_SHAPE_MS);
if (cycle > this.prevCycle) {
this.morphTarget += cycle - this.prevCycle;
this.spring.target = this.morphTarget;
this.prevCycle = cycle;
}
const fraction = (this.elapsed % DURATION_PER_SHAPE_MS) / DURATION_PER_SHAPE_MS;
this.spring.step(dt);
const base = this.morphTarget - 1;
const perShape = this.spring.pos - base;
this.rotation =
((CONSTANT_ROTATION_DEG + EXTRA_ROTATION_DEG) * base +
CONSTANT_ROTATION_DEG * fraction +
EXTRA_ROTATION_DEG * perShape) %
360;
this.morph = this.spring.pos;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { Doc } from "./tokens";
import { isProject } from "./project";
/* A design travels in a link: the document as JSON, deflated and base64url-encoded
* after `#docz=`, or plain JSON after `#doc=` for tools that cannot compress. The
* fragment never reaches the server, so the static site stays static. */
export const DOC_PARAM = "doc";
export const DOCZ_PARAM = "docz";
/** the document with what a link should not carry: picked images and AI rewrite history */
export function shareable(doc: Doc): Doc {
return {
...doc,
frames: doc.frames.map(({ noteHistory: _h, ...f }) => f),
groups: doc.groups.map((g) => ({
...g,
items: g.items.map(({ src, noteHistory: _h, ...it }) => (src && /^https?:\/\//.test(src) ? { ...it, src } : it)),
})),
};
}
const toBase64Url = (bytes: Uint8Array) => {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
};
const fromBase64Url = (s: string) => {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (s.length % 4)) % 4);
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
};
async function pipe(bytes: Uint8Array, stream: { readable: ReadableStream; writable: WritableStream }): Promise<Uint8Array> {
const writer = stream.writable.getWriter();
/* the writer's own promises reject too on bad input; the read below reports the failure */
writer.write(bytes).catch(() => {});
writer.close().catch(() => {});
return new Uint8Array(await new Response(stream.readable).arrayBuffer());
}
/** the link that opens `doc` at `base` (the app URL without a hash) */
export async function shareLink(doc: Doc, base: string): Promise<string> {
const json = JSON.stringify(shareable(doc));
if (typeof CompressionStream !== "undefined") {
const packed = await pipe(new TextEncoder().encode(json), new CompressionStream("deflate-raw"));
return `${base}#${DOCZ_PARAM}=${toBase64Url(packed)}`;
}
return `${base}#${DOC_PARAM}=${encodeURIComponent(json)}`;
}
/** the document a hash carries, or null when it carries none or a broken one */
export async function readShareHash(hash: string): Promise<Doc | null> {
const params = new URLSearchParams(hash.replace(/^#/, ""));
try {
let json: string | null = null;
const packed = params.get(DOCZ_PARAM);
if (packed) {
const bytes = await pipe(fromBase64Url(packed), new DecompressionStream("deflate-raw"));
json = new TextDecoder().decode(bytes);
} else {
json = params.get(DOC_PARAM);
}
if (!json) return null;
const value: unknown = JSON.parse(json);
return isProject(value) ? value : null;
} catch {
return null;
}
}
/** whether a hash claims to carry a document at all */
export const hasShareHash = (hash: string) => {
const params = new URLSearchParams(hash.replace(/^#/, ""));
return params.has(DOCZ_PARAM) || params.has(DOC_PARAM);
};
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { createContext, useContext } from "react";
import { DEFAULT_THEME, FONTS, FontKey, LANG_FONT, Theme } from "./tokens";
import type { Lang } from "./i18n";
/** The document theme, read by parts that render differently under it
* (emphasized type, motion scheme). Shape goes through the tokens helpers. */
export const ThemeContext = createContext<Theme>(DEFAULT_THEME);
export const useTheme = () => useContext(ThemeContext);
const loaded = new Set<FontKey>();
/** Fetch a Google font the first time it is chosen; the built-in faces need nothing.
* `onReady` fires once the face is usable, so measured widths can be refreshed. */
export function ensureFontLoaded(key: FontKey, onReady?: () => void) {
const f = FONTS.find((x) => x.key === key);
if (typeof document === "undefined") return;
const ready = () => document.fonts?.ready.then(() => onReady?.());
if (!f?.google || loaded.has(key)) {
ready();
return;
}
loaded.add(key);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?family=${f.google}&display=swap`;
link.onload = () => {
/* the stylesheet is in, but the face itself downloads on first use: load it explicitly */
const face = f.family.split(",")[0].trim().replace(/^'|'$/g, "");
Promise.all([400, 500, 600, 700].map((w) => document.fonts.load(`${w} 16px "${face}"`))).then(ready, ready);
};
document.head.appendChild(link);
}
const loadedLangs = new Set<Lang>();
/** Fetch the Noto Sans face for a language the first time it is used. */
export function ensureLangFontLoaded(lang: Lang, onReady?: () => void) {
const f = LANG_FONT[lang];
if (typeof document === "undefined") return;
const ready = () => document.fonts?.ready.then(() => onReady?.());
if (!f || loadedLangs.has(lang)) {
ready();
return;
}
loadedLangs.add(lang);
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `https://fonts.googleapis.com/css2?family=${f.google}&display=swap`;
link.onload = () => {
const face = f.family.replace(/^'|'$/g, "");
Promise.all([400, 500, 600, 700].map((w) => document.fonts.load(`${w} 16px "${face}"`))).then(ready, ready);
};
document.head.appendChild(link);
}
+52
View File
@@ -0,0 +1,52 @@
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();
});
});
+502
View File
@@ -0,0 +1,502 @@
import { CONTENT_W, FULL_WIDTH, Frame, Group, Item, KIND_SPEC, Kind, PHONE_MARGIN, RAIL_W, canJoin, isPhoneFrame, scaleR, carryItemSize, connectSpecOf, frameOfGroup, frameRect, frameSizeOf, groupBounds, isExpanded } from "./tokens";
/* Rule-based layout for one screen. Nothing here is guessed by a model.
*
* 1. Parts of one connectable family that sit next to each other, or on top of
* each other, fuse into a connected run, the same way the magnetic drop does:
* list items stacked in a column, buttons or icon buttons side by side in a row.
* 2. Bars stick to the edges they belong to (app bar and tabs at the top, the
* navigation bar at the bottom, toolbars and snackbars hovering above it,
* a bottom sheet on the bottom edge), a FAB takes the bottom-right corner,
* a dialog is centered.
* 3. Everything else is stacked from the top on the 16dp layout margins. Rows,
* hand-made groups and intentional overlaps (a badge on an icon, parts on a
* box) are kept as one unit, and a part keeps the side it was on. A run
* nearly as wide as the content sits on the margin; a text with a switch,
* checkbox or radio beside it spans the row, the control on the right.
*
* 4. Inside one unit (parts on a box, a hand-made group) edges that are almost
* aligned snap to the common line; across the screen, boxes whose corners are
* almost the same radius become the same, and likewise cards. Both only act on
* differences a hand would call a slip, never on a gap someone clearly meant.
*
* Only positions change and runs are joined; sizes, order and contents stay,
* apart from a corner radius evened out by rule 4. */
/** the farthest two edges may be apart and still count as meant to line up */
const SNAP = 6;
/** the farthest two corner radii may be apart and still count as meant to match */
const RADIUS_SNAP = 4;
type Rect = { l: number; t: number; r: number; b: number };
/** vertical distance between stacked rows */
const ROW_GAP = 16;
/** a tighter gap after a heading and between list-like rows of one kind */
const TIGHT_GAP = 8;
/** horizontal distance between parts packed into one row */
const ROW_ITEM_GAP = 8;
/** the farthest two parts of one family may be apart and still be joined: side by side, and stacked */
const JOIN_GAP_X = 24;
const JOIN_GAP_Y = 48;
/** parts of one family the author kept apart stay clearly apart, beyond the joining distance */
const APART_GAP_X = JOIN_GAP_X + 8;
const APART_GAP_Y = JOIN_GAP_Y + 8;
const LIST_KINDS = new Set(["listItem", "textField", "checkbox", "radio", "switch", "chip", "divider", "card"]);
/** one movable unit: a group plus everything nested inside or overlapping it */
type Unit = { ids: string[]; bb: Rect; kind: string; checked?: boolean; /** the first part, for family checks */ probe: Item };
const overlap = (a: Rect, b: Rect) => Math.min(a.r, b.r) > Math.max(a.l, b.l) && Math.min(a.b, b.b) > Math.max(a.t, b.t);
const union = (a: Rect, b: Rect): Rect => ({ l: Math.min(a.l, b.l), t: Math.min(a.t, b.t), r: Math.max(a.r, b.r), b: Math.max(a.b, b.b) });
/** how much of the smaller extent two spans share, 0..1 */
const share = (a0: number, a1: number, b0: number, b1: number) => Math.max(0, Math.min(a1, b1) - Math.max(a0, b0)) / Math.max(1, Math.min(a1 - a0, b1 - b0));
/* ---------- 1. join runs ---------- */
/** whether the whole run is one connectable family, so a neighbour can join it */
const runFamily = (g: Group): { axis: "x" | "y"; probe: Item } | null => {
if (g.free) return null;
const spec = connectSpecOf(g.items[0]);
if (!spec) return null;
if (!g.items.every((it) => canJoin(g.items[0], it))) return null;
return { axis: spec.axis, probe: g.items[0] };
};
/** Neighbouring runs of one family fuse into a single run, in reading order. */
function joinRuns(groups: Group[], widths: Record<string, number>): Group[] {
const out = [...groups];
for (;;) {
let joined = false;
const bounds = new Map(out.map((g) => [g.id, groupBounds(g, widths)]));
outer: for (let i = 0; i < out.length; i++) {
const a = out[i];
const fa = runFamily(a);
if (!fa) continue;
for (let j = 0; j < out.length; j++) {
if (i === j) continue;
const b = out[j];
const fb = runFamily(b);
if (!fb || fa.axis !== fb.axis || !canJoin(fa.probe, fb.probe)) continue;
const ra = bounds.get(a.id)!;
const rb = bounds.get(b.id)!;
/* b must come right after a along the axis, and line up across it; parts dropped onto
* each other count as neighbours too, read from the one that starts first */
const gap = fa.axis === "x" ? rb.l - ra.r : rb.t - ra.b;
if (gap > (fa.axis === "x" ? JOIN_GAP_X : JOIN_GAP_Y)) continue;
const ahead = fa.axis === "x" ? ra.l < rb.l || (ra.l === rb.l && i < j) : ra.t < rb.t || (ra.t === rb.t && i < j);
if (gap < -2 && !ahead) continue;
const lined = fa.axis === "x" ? share(ra.t, ra.b, rb.t, rb.b) : share(ra.l, ra.r, rb.l, rb.r);
if (lined < 0.5) continue;
/* nothing else may sit between them */
const between: Rect = fa.axis === "x" ? { l: ra.r, t: Math.min(ra.t, rb.t), r: rb.l, b: Math.max(ra.b, rb.b) } : { l: Math.min(ra.l, rb.l), t: ra.b, r: Math.max(ra.r, rb.r), b: rb.t };
if (out.some((o) => o !== a && o !== b && overlap(bounds.get(o.id)!, between))) continue;
const merged: Group = { ...a, axis: fa.axis, items: [...a.items, ...b.items] };
/* the run keeps the later layer so the joined parts still draw above whatever they were over */
const at = Math.max(i, j);
out.splice(at, 1, merged);
out.splice(Math.min(i, j), 1);
joined = true;
break outer;
}
}
if (!joined) return out;
}
}
/* ---------- 2 and 3. place ---------- */
const area = (r: Rect) => Math.max(0, r.r - r.l) * Math.max(0, r.b - r.t);
/** Groups that touch each other stay together, so a badge on an icon or parts on a box move as one.
* Bars, FABs and dialogs never join a cluster: they have their own place and are often drawn over content. */
function clusters(groups: Group[], widths: Record<string, number>): Unit[] {
const units: Unit[] = groups.map((g) => ({ ids: [g.id], bb: groupBounds(g, widths), kind: g.items[0].kind, checked: g.items[0].checked, probe: g.items[0] }));
for (;;) {
let merged = false;
outer: for (let i = 0; i < units.length; i++) {
if (isAnchored(units[i])) continue;
for (let j = i + 1; j < units.length; j++) {
if (isAnchored(units[j]) || !overlap(units[i].bb, units[j].bb)) continue;
/* the larger member names the cluster: it is the container, the rest sits on it */
const big = area(units[i].bb) >= area(units[j].bb) ? units[i] : units[j];
units[i] = { ...big, ids: [...units[i].ids, ...units[j].ids], bb: union(units[i].bb, units[j].bb) };
units.splice(j, 1);
merged = true;
break outer;
}
}
if (!merged) return units;
}
}
/** Units whose vertical extents overlap and that sit side by side form one row. */
function rowsOf(units: Unit[]): Unit[][] {
const sorted = [...units].sort((a, b) => a.bb.t - b.bb.t || a.bb.l - b.bb.l);
const out: Unit[][] = [];
for (const u of sorted) {
const row = out[out.length - 1];
if (row) {
const rt = Math.min(...row.map((r) => r.bb.t));
const rb = Math.max(...row.map((r) => r.bb.b));
const cy = (u.bb.t + u.bb.b) / 2;
const rcy = (rt + rb) / 2;
const beside = row.every((r) => r.bb.r <= u.bb.l + 2 || r.bb.l >= u.bb.r - 2);
if (beside && ((cy >= rt && cy <= rb) || (rcy >= u.bb.t && rcy <= u.bb.b))) {
row.push(u);
continue;
}
}
out.push([u]);
}
for (const r of out) r.sort((a, b) => a.bb.l - b.bb.l);
return out;
}
const isRail = (u: Unit) => u.kind === "navRail";
const isTop = (u: Unit) => u.kind === "topAppBar" || u.kind === "tabs";
const isBottomBar = (u: Unit) => u.kind === "bottomNav" || (u.kind === "box" && !!u.checked);
const isFloatingBottom = (u: Unit) => u.kind === "toolbar" || u.kind === "snackbar";
const isFab = (u: Unit) => u.kind === "fab" || u.kind === "extendedFab" || u.kind === "fabMenu";
const isOverlay = (u: Unit) => u.kind === "dialog";
/** a line of text, and the small controls that pair with one across a row */
const isLabel = (u: Unit) => u.kind === "text";
const isControl = (u: Unit) => u.kind === "switch" || u.kind === "checkbox" || u.kind === "radio" || u.kind === "iconButton";
const isAnchored = (u: Unit) => isRail(u) || isTop(u) || isBottomBar(u) || isFloatingBottom(u) || isFab(u) || isOverlay(u);
/** where a unit sits horizontally, so tidying keeps a right-aligned part on the right.
* Judged from the edges, so a part already on the margin reads the same way after tidying. */
function align(bb: Rect, fr: Rect): "left" | "center" | "right" | "fill" {
const w = bb.r - bb.l;
if (w >= fr.r - fr.l - PHONE_MARGIN * 2 - 8) return "fill";
const near = PHONE_MARGIN + 12;
const left = bb.l - fr.l <= near;
const right = fr.r - bb.r <= near;
if (left && !right) return "left";
if (right && !left) return "right";
return "center";
}
/** the gap above a row, from what came before it */
function gapBefore(prev: Unit[] | null, row: Unit[]): number {
if (!prev) return 0;
/* two stacked runs of one family were left separate on purpose: keep them beyond the joining distance */
if (prev.length === 1 && row.length === 1 && canJoin(prev[0].probe, row[0].probe) && connectSpecOf(prev[0].probe)?.axis === "y") return APART_GAP_Y;
const pk = prev.length === 1 ? prev[0].kind : null;
const k = row.length === 1 ? row[0].kind : null;
if (pk === "text") return TIGHT_GAP;
if (pk === "divider" || k === "divider") return TIGHT_GAP;
if (pk && k && pk === k && LIST_KINDS.has(k)) return TIGHT_GAP;
return ROW_GAP;
}
/** A group moved the least distance that puts it inside a screen; an edge-to-edge
* part sits on the left edge. A group that fits already is returned as is. */
export function pullInto(g: Group, frame: Frame, widths: Record<string, number>): Group {
const fr = frameRect(frame);
const bb = groupBounds(g, widths);
let dx = bb.r > fr.r ? Math.max(fr.l - bb.l, fr.r - bb.r) : bb.l < fr.l ? fr.l - bb.l : 0;
const dy = bb.b > fr.b ? Math.max(fr.t - bb.t, fr.b - bb.b) : bb.t < fr.t ? fr.t - bb.t : 0;
/* a bar as wide as the screen sits on its left edge; a narrower one keeps its place */
if (g.items.length === 1 && FULL_WIDTH.includes(g.items[0].kind) && bb.r - bb.l >= fr.r - fr.l) dx = fr.l - bb.l;
return dx || dy ? { ...g, x: g.x + Math.round(dx), y: g.y + Math.round(dy) } : g;
}
/** A screen changing size, and everything that follows from it: the screens to its
* right move over with their parts so nothing overlaps, the parts of the screen
* take the sizes the new screen calls for, anything that fell outside is pulled
* back in, and the screen is laid out again by the rules above. */
export function carryFrame(groups: Group[], frame: Frame, to: Frame, frames: Frame[], widths: Record<string, number>): { frames: Frame[]; groups: Group[] } {
const from = frameSizeOf(frame);
const after = frameSizeOf(to);
const owner = new Map(groups.map((g) => [g.id, frameOfGroup(g, frames, widths)?.id] as const));
/* screens whose left edge is past the old right edge keep their distance from it */
const shift = after.w - from.w;
const moved = new Map(frames.filter((f) => f.id !== frame.id && f.x >= frame.x + from.w).map((f) => [f.id, shift] as const));
const nextFrames = frames.map((f) => (f.id === to.id ? to : moved.has(f.id) ? { ...f, x: f.x + shift } : f));
/* The navigation bar of a compact screen is the rail of an expanded one, and back: M3 has
* no rail below 840dp and no bar above it, so a rail placed on a phone becomes a bar too.
* Only a bar or rail standing on its own is swapped, and only the first of them: one
* inside a hand-made group is part of that group's drawing, and a screen has one rail. */
const expanded = isExpanded(after.w);
const mine = groups.filter((g) => owner.get(g.id) === frame.id);
const standsAlone = (g: Group, kind: Kind) => g.items.length === 1 && g.items[0].kind === kind;
const navGroup = mine.find((g) => standsAlone(g, "bottomNav") || standsAlone(g, "navRail"));
const swapNav = (g: Group, it: Item): Item => {
if (g !== navGroup) return it;
if (expanded && it.kind === "bottomNav") return { ...it, kind: "navRail", size: undefined, size2: after.h, radiusTop: it.radiusBottom, radiusBottom: it.radiusTop };
if (!expanded && it.kind === "navRail") return { ...it, kind: "bottomNav", size: after.w, size2: undefined, radiusTop: it.radiusBottom, radiusBottom: it.radiusTop };
return it;
};
const railBefore = mine.some((g) => standsAlone(g, "navRail")) ? RAIL_W : 0;
const railAfter = expanded && navGroup ? RAIL_W : 0;
/* a rail keeps the side it stood on; one that grows out of a bar starts on the left */
const side = navGroup && standsAlone(navGroup, "navRail") ? railSide(navGroup, frame, widths) : "left";
const leftBefore = side === "left" ? railBefore : 0;
const leftAfter = side === "left" ? railAfter : 0;
/* parts live in the body beside the rail: their widths follow the body, and their
* positions are measured from its left edge, on both ends of the change */
const fromBody = { w: from.w - railBefore, h: from.h };
const toBody = { w: after.w - railAfter, h: after.h };
const resized = groups.map((g) => {
const o = owner.get(g.id);
if (o === frame.id) {
const isRail = g === navGroup && railAfter > 0;
const items = g.items.map((it) => swapNav(g, carryItemSize(it, isRail ? from : fromBody, isRail ? after : toBody)));
const x = isRail ? (side === "right" ? to.x + after.w - RAIL_W : to.x) : g.x + leftAfter - leftBefore;
return pullInto({ ...g, x, items }, to, widths);
}
const dx = o ? moved.get(o) : undefined;
return dx ? { ...g, x: g.x + dx } : g;
});
return { frames: nextFrames, groups: tidyFrame(resized, to, nextFrames, widths) ?? resized };
}
/** the edge a rail belongs to: whichever side of the screen its middle is nearer */
export function railSide(g: Group, frame: Frame, widths: Record<string, number>): "left" | "right" {
const fr = frameRect(frame);
const bb = groupBounds(g, widths);
return (bb.l + bb.r) / 2 > (fr.l + fr.r) / 2 ? "right" : "left";
}
/** where a bar dropped on a screen goes: the screen's width less its rails, starting after a rail on the left */
export function barSlotOf(groups: Group[], frame: Frame, frames: Frame[], widths: Record<string, number>): { x: number; w: number } {
const { w } = frameSizeOf(frame);
const rails = groups.filter((g) => frameOfGroup(g, frames, widths)?.id === frame.id && g.items.length === 1 && g.items[0].kind === "navRail");
const left = rails.filter((g) => railSide(g, frame, widths) === "left").length;
return { x: frame.x + left * RAIL_W, w: w - rails.length * RAIL_W };
}
/** The value most of a set share, within `tol`: the biggest cluster wins; `prefer` breaks a tie, else the first seen. */
function commonValue(values: number[], tol: number, prefer?: number): number | null {
let best: { v: number; n: number } | null = null;
for (const v of values) {
const n = values.filter((w) => Math.abs(w - v) <= tol).length;
if (!best || n > best.n || (n === best.n && v === prefer && best.v !== prefer)) best = { v, n };
}
return best && best.n >= 2 ? best.v : null;
}
/** Runs inside one unit whose left, right or top edges nearly agree move to the shared line.
* A run is only ever nudged by up to SNAP, so the picture stays what the author drew. */
function snapEdges(unit: Unit, groups: Map<string, Group>, widths: Record<string, number>): boolean {
if (unit.ids.length < 2) return false;
const runs = unit.ids.map((id) => groups.get(id)!);
const rects = new Map(runs.map((g) => [g.id, groupBounds(g, widths)]));
/* the container (the biggest run) sets the line; the parts on it follow */
const container = runs.reduce((a, b) => (area(rects.get(a.id)!) >= area(rects.get(b.id)!) ? a : b));
const others = runs.filter((g) => g !== container);
if (!others.length) return false;
let moved = false;
const nudge = (g: Group, dx: number, dy: number) => {
if (!dx && !dy) return;
groups.set(g.id, { ...groups.get(g.id)!, x: groups.get(g.id)!.x + dx, y: groups.get(g.id)!.y + dy });
moved = true;
};
const lefts = commonValue(others.map((g) => rects.get(g.id)!.l), SNAP);
const rights = commonValue(others.map((g) => rects.get(g.id)!.r), SNAP);
for (const g of others) {
const r = rects.get(g.id)!;
/* a run already on one shared line is left alone, so a left snap never turns into a right snap later */
if (r.l === lefts || r.r === rights) continue;
const dl = lefts === null ? Infinity : Math.abs(r.l - lefts);
const dr = rights === null ? Infinity : Math.abs(r.r - rights);
if (dl <= SNAP && dl <= dr) nudge(g, Math.round(lefts! - r.l), 0);
else if (dr <= SNAP) nudge(g, Math.round(rights! - r.r), 0);
}
/* parts side by side that nearly share a top edge: the neighbours set the line, not the part itself */
for (const g of others) {
const r = groupBounds(groups.get(g.id)!, widths);
const beside = others.filter((o) => o !== g && share(r.t, r.b, rects.get(o.id)!.t, rects.get(o.id)!.b) > 0.5).map((o) => groupBounds(groups.get(o.id)!, widths).t);
const tops = beside.length === 1 ? beside[0] : commonValue(beside, SNAP);
if (tops !== null && r.t !== tops && Math.abs(r.t - tops) <= SNAP) nudge(g, 0, Math.round(tops - r.t));
}
return moved;
}
/** Boxes on the screen whose corner radii almost agree take the common radius, and so do
* cards, each kind among its own. A card still on the shape scale is not touched: writing a
* radius into it would cut it loose from the Shape control. */
function evenCorners(groups: Map<string, Group>): boolean {
const boxes: { g: Group; it: Item; r: number }[] = [];
const cards: { g: Group; it: Item; r: number }[] = [];
for (const g of groups.values())
for (const it of g.items) {
if (it.kind === "card" && it.radiusTop !== undefined) cards.push({ g, it, r: it.radiusTop });
else if (it.kind === "box" && !it.corners && (it.radiusTop ?? 0) === (it.radiusBottom ?? 0)) boxes.push({ g, it, r: it.radiusTop ?? 0 });
}
let changed = false;
const even = (list: typeof boxes, prefer: number) => {
const common = commonValue(list.map((b) => b.r), RADIUS_SNAP, prefer);
if (common === null) return;
for (const { g, it, r } of list) {
if (r === common || Math.abs(r - common) > RADIUS_SNAP) continue;
const cur = groups.get(g.id)!;
groups.set(g.id, { ...cur, items: cur.items.map((x) => (x.id === it.id ? (x.kind === "card" ? { ...x, radiusTop: common } : { ...x, radiusTop: common, radiusBottom: common }) : x)) });
changed = true;
}
};
even(boxes, 28);
even(cards, scaleR(KIND_SPEC.card.radius));
return changed;
}
/** The tidied groups of the document, or null when `frame` is already tidy. */
export function tidyFrame(groups: Group[], frame: Frame, frames: Frame[], widths: Record<string, number>): Group[] | null {
const screen: Rect = frameRect(frame);
const mineIds = new Set(groups.filter((g) => frameOfGroup(g, frames, widths)?.id === frame.id).map((g) => g.id));
if (!mineIds.size) return null;
/* joining rewrites the list; the other screens' groups keep their slots */
const before = groups.filter((g) => mineIds.has(g.id));
/* a labelled switch standing on its own on a phone screen reads as a settings row: it takes
* the content width, label left, switch right. One on a box, in a group or on a desktop
* screen keeps its size. */
let widened = false;
const alone = (g: Group) => {
const r = groupBounds(g, widths);
/* nothing on it, and nothing beside it on the same row, which would have to share the width */
return !before.some((o) => {
if (o === g) return false;
const ob = groupBounds(o, widths);
return overlap(ob, r) || share(r.t, r.b, ob.t, ob.b) > 0.5;
});
};
const spanned = before.map((g) => {
const it = g.items[0];
if (!isPhoneFrame(frame) || g.items.length !== 1 || it.kind !== "switch" || !it.label.trim() || it.size || !alone(g)) return g;
widened = true;
return { ...g, items: [{ ...it, size: CONTENT_W }] };
});
const mine = joinRuns(spanned, widths);
const joined = mine.length !== before.length || widened;
const units = clusters(mine, widths);
const target = new Map<Unit, { l: number; t: number }>();
/* a navigation rail stands on the edge it is nearer to, left or right (a second one beside it);
* the rest of the screen is the body between them */
const rails = units.filter(isRail).sort((a, b) => a.bb.l - b.bb.l || a.bb.t - b.bb.t);
const onRight = (u: Unit) => (u.bb.l + u.bb.r) / 2 > (screen.l + screen.r) / 2;
const leftRails = rails.filter((u) => !onRight(u));
const rightRails = rails.filter(onRight);
leftRails.forEach((u, i) => target.set(u, { l: screen.l + i * RAIL_W, t: screen.t }));
rightRails.forEach((u, i) => target.set(u, { l: screen.r - (i + 1) * RAIL_W, t: screen.t }));
const fr: Rect = { ...screen, l: screen.l + leftRails.length * RAIL_W, r: screen.r - rightRails.length * RAIL_W };
const frameW = fr.r - fr.l;
const frameH = fr.b - fr.t;
let top = fr.t;
for (const u of units.filter(isTop).sort((a, b) => a.bb.t - b.bb.t)) {
target.set(u, { l: fr.l, t: top });
top += u.bb.b - u.bb.t;
}
let bottom = fr.b;
for (const u of units.filter(isBottomBar).sort((a, b) => b.bb.t - a.bb.t)) {
bottom -= u.bb.b - u.bb.t;
target.set(u, { l: fr.l + Math.max(0, Math.round((frameW - (u.bb.r - u.bb.l)) / 2)), t: bottom });
}
for (const u of units.filter(isFloatingBottom).sort((a, b) => b.bb.t - a.bb.t)) {
const h = u.bb.b - u.bb.t;
const w = u.bb.r - u.bb.l;
bottom -= PHONE_MARGIN + h;
target.set(u, { l: fr.l + Math.round((frameW - w) / 2), t: bottom });
}
let fabBottom = bottom;
for (const u of units.filter(isFab).sort((a, b) => b.bb.t - a.bb.t)) {
const h = u.bb.b - u.bb.t;
const w = u.bb.r - u.bb.l;
fabBottom -= PHONE_MARGIN + h;
target.set(u, { l: fr.r - PHONE_MARGIN - w, t: fabBottom });
}
for (const u of units.filter(isOverlay)) {
target.set(u, { l: fr.l + Math.round((frameW - (u.bb.r - u.bb.l)) / 2), t: fr.t + Math.round((frameH - (u.bb.b - u.bb.t)) / 2) });
}
/* everything else flows from the top on the layout margins, down to the bottom bars;
* rows that would not fit are left where they are rather than pushed off the screen */
const rows = rowsOf(units.filter((u) => !target.has(u)));
const limit = bottom - PHONE_MARGIN;
let y = top + PHONE_MARGIN;
let prev: Unit[] | null = null;
for (const row of rows) {
y += gapBefore(prev, row);
const rowH = Math.max(...row.map((u) => u.bb.b - u.bb.t));
if (y + rowH > limit) break;
const inner = frameW - PHONE_MARGIN * 2;
if (row.length === 1) {
const u = row[0];
const w = u.bb.r - u.bb.l;
const a = align(u.bb, fr);
/* a run nearly as wide as the content (a button row, a chip row) sits on the left margin,
* so its edge lines up with the cards and lists above and below it */
const wide = w >= inner * 0.7;
const l = wide || a === "left" ? fr.l + PHONE_MARGIN : a === "right" ? fr.r - PHONE_MARGIN - w : fr.l + Math.round((frameW - w) / 2);
target.set(u, { l, t: y });
} else if (row.length === 2 && row.some(isLabel) && row.some(isControl)) {
/* a label with its control: the label on the left margin, the control on the right, like a settings row */
const label = row.find(isLabel)!;
const control = row.find(isControl)!;
const cw = control.bb.r - control.bb.l;
target.set(label, { l: fr.l + PHONE_MARGIN, t: y + Math.round((rowH - (label.bb.b - label.bb.t)) / 2) });
target.set(control, { l: fr.r - PHONE_MARGIN - cw, t: y + Math.round((rowH - (control.bb.b - control.bb.t)) / 2) });
} else {
const ws = row.map((u) => u.bb.r - u.bb.l);
const total = ws.reduce((s, w) => s + w, 0);
/* neighbours of one family that were not joined were kept apart on purpose */
const gaps = row.slice(1).map((u, i) => (canJoin(row[i].probe, u.probe) ? APART_GAP_X : ROW_ITEM_GAP));
const minPacked = total + gaps.reduce((s, g) => s + g, 0);
const span = row[row.length - 1].bb.r - row[0].bb.l;
const spread = span >= inner * 0.7 && minPacked <= inner;
const packed = spread ? inner : minPacked;
const extra = spread ? (inner - minPacked) / gaps.length : 0;
const a = spread ? "left" : align({ l: row[0].bb.l, t: 0, r: row[row.length - 1].bb.r, b: 0 }, fr);
let x = a === "right" ? fr.r - PHONE_MARGIN - packed : a === "center" ? fr.l + (frameW - packed) / 2 : fr.l + PHONE_MARGIN;
row.forEach((u, i) => {
const h = u.bb.b - u.bb.t;
target.set(u, { l: Math.round(x), t: y + Math.round((rowH - h) / 2) });
x += ws[i] + (gaps[i] ?? 0) + extra;
});
}
y += rowH;
prev = row;
}
/* apply each unit's shift to every group it holds */
const shift = new Map<string, { dx: number; dy: number }>();
for (const [u, to] of target) {
const dx = Math.round(to.l - u.bb.l);
const dy = Math.round(to.t - u.bb.t);
for (const id of u.ids) shift.set(id, { dx, dy });
}
let moved = joined;
const placed = new Map(
mine.map((g) => {
const s = shift.get(g.id);
if (!s || (s.dx === 0 && s.dy === 0)) return [g.id, g] as const;
moved = true;
return [g.id, { ...g, x: g.x + s.dx, y: g.y + s.dy }] as const;
}),
);
for (const u of units) if (snapEdges(u, placed, widths)) moved = true;
if (evenCorners(placed)) moved = true;
if (!moved) return null;
/* keep canvas order: a joined run takes the slot of its last original member */
const survivorOf = new Map<string, Group>();
for (const m of mine) for (const it of m.items) survivorOf.set(it.id, m);
const lastSlot = new Map<string, number>();
groups.forEach((g, i) => {
if (mineIds.has(g.id)) lastSlot.set(survivorOf.get(g.items[0].id)!.id, i);
});
const out: Group[] = [];
groups.forEach((g, i) => {
if (!mineIds.has(g.id)) {
out.push(g);
return;
}
const m = survivorOf.get(g.items[0].id)!;
if (lastSlot.get(m.id) === i) out.push(placed.get(m.id)!);
});
return out;
}
+69
View File
@@ -0,0 +1,69 @@
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);
});
});
+1662
View File
File diff suppressed because it is too large Load Diff