Files
m3e-canvas/web/vendor/lib/tokens.ts
T
pastilhas 80529dd9e7 six defects, and a typecheck the repo could not pass
Found by reviewing the plugin rather than running it. Nothing was live — the
table does not exist and the plugin is not installed — so all of these were
waiting for the first person to use it.

── The second unnamed design could not be saved ──

`uniqueIndex(userId, title)`, and every design starts life called "Untitled":
`titleOf` returns it for a document with no title and the API defaults to it. So
the first saved, the second collided, and the insert threw under a panel that
could only say "Changes are not being saved." Renaming one design to match
another did the same.

Two designs may share a name, as files and notes and dashboards do. Nothing looks
a design up by title, so uniqueness bought nothing. The index is now ordinary and
on the columns the list query actually uses.

The lesson from `dashboards.id` that the schema comment cites is that a key must
be scoped to its owner. `userId` on the row does that; making the pair unique as
well imported the collision it was citing, one account at a time.

── New did not start a new design ──

It cleared `?doc=`, and no parameter already means "open what I had last",
answered with `/documents/latest`. So New handed back the design on screen and
pointed the next save at its id. There is a `new` sentinel now, in its own module
because both panels read it and neither owns it.

── Two ways the load effect raced the debounced save ──

Changing `?doc=` does not unmount the panel, so the unmount flush never ran for
it: an edit made within the debounce and navigated away from was written after
`latest.current` had been replaced, which is to say never.

And the first save of a new design puts its id in the URL, which lands in the
same effect — re-reading the row overwrote in-memory state with the copy the
server had just been given, discarding whatever was drawn during the round trip.

The effect now flushes what is pending before it reads, and does not re-read a
document it already holds. The editor is keyed on a load generation rather than
on `row?.id`, which could not express "blank again" and left the previous design
on screen with `rowId` cleared underneath it.

── The typecheck ──

354 of the 355 errors were upstream's tree measured against this platform's
settings, which it was never written for. `bunx tsgo` is how this repo is kept
clean and it could not be clean while this plugin existed. `exclude` does not
help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file
unchecked in the same deterministic pass that rewrites its imports. Types still
resolve; only the errors inside those files are silenced, which is what the
adapter already says the seam is for. Whole repo: 0.

── PROVENANCE.md understated what is done to the copy ──

It said "the one edit vendor.sh makes" and described the import rewrite. It also
runs apply-host.py, which places our own lib/host.ts and wires three export
paths. Four files are not upstream and the document whose job is making the copy
trustworthy did not say so. It lists all four now.

Also: web/host.ts imported a path only correct at its destination, so the file
could never typecheck where it is edited — apply-host.py rewrites that line when
placing it, and re-marks the placed copy, which it overwrites after vendor.sh has
marked it. PUT is no longer full-replace: omitting `title` renamed a design to
"Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a
document is bounded at 4 MB, measured in bytes, where the title was capped at 200
and the document at nothing.
2026-09-05 19:52:27 +01:00

1664 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @ts-nocheck — vendored, see PROVENANCE.md
import type { CSSProperties } from "react";
import { FAB_MENU_TABS, KIND_TEXT, Lang, NAV_TABS, TAB_LABELS, getLang, t } from "./i18n";
import { Contrast, schemeFromSeed } from "./color";
/* ---------- geometry ---------- */
export const H = 56; // M3 medium button height (dp)
export const GAP = 3; // connected group spacing
export const R_FULL = 28; // outer corner of a connected run
export const R_INNER = 8; // inner corner when connected (M3 small)
/** magnetic field size, along the run and across it */
export const SNAP_MAIN = 44;
export const SNAP_CROSS = 24;
/** how sharply the pull ramps up (higher = gentler at the edge) */
export const PULL_EXP = 2.2;
/** ms allowed for the landing animation before the item is committed */
export const SETTLE_MS = 340;
/** phone screen used by the "phone" canvas mode (Pixel-like, dp) */
/* Pixel-class phone, 412 dp wide; kept at 892 dp tall so the whole screen fits the canvas */
export const PHONE_W = 412;
export const PHONE_H = 892;
export const PHONE_R = 40;
export const DESKTOP_W = 1280;
export const DESKTOP_H = 800;
export const DESKTOP_R = 28;
/** M3 window size classes: a screen this wide is "expanded", where the navigation bar becomes a rail */
export const EXPANDED_W = 840;
export const isExpanded = (w: number) => w >= EXPANDED_W;
/** navigation rail: width, top inset and the pitch of one destination (56×32 indicator, label, gap) */
export const RAIL_W = 80;
export const RAIL_TOP = 44;
export const RAIL_ITEM_H = 52;
export const RAIL_GAP = 12;
/** system insets: the status bar above a top app bar and the gesture area below a navigation bar.
* Both bars carry their inset as extra height so their background reaches the rounded screen edge. */
export const STATUS_BAR_H = 24;
export const NAV_BAR_H = 24;
/** M3 layout margin: parts that are not edge-to-edge sit this far from the screen edge */
export const PHONE_MARGIN = 16;
export const contentWidth = (width: number) => width - PHONE_MARGIN * 2;
export const halfWidth = (width: number) => (contentWidth(width) - PHONE_MARGIN) / 2;
/** width of a part that spans the screen with a margin on both sides */
export const CONTENT_W = contentWidth(PHONE_W);
/** width of one of two parts sharing a row, with a margin-sized gutter between them */
export const HALF_W = halfWidth(PHONE_W);
/** width presets offered in the inspector: two columns, with margins, edge-to-edge */
export const WIDTH_PRESETS = [HALF_W, CONTENT_W, PHONE_W];
/** height presets for free-form boxes: half the screen, the whole screen */
export const HEIGHT_PRESETS = [PHONE_H / 2, PHONE_H];
/** bezel around the screen and the label above it */
export const BEZEL = 10;
export const FRAME_LABEL_H = 44;
/** horizontal distance between newly added frames */
export const FRAME_GAP = 120;
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
export const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
export const uid = () => Math.random().toString(36).slice(2, 10);
export type Radii = { tl: number; tr: number; bl: number; br: number };
export const uniformRadii = (r: number): Radii => ({ tl: r, tr: r, bl: r, br: r });
/* ---------- color ---------- */
export type Palette = {
key: string;
label: string;
/** the color a custom scheme was generated from */
seed?: string;
primary: string;
onPrimary: string;
primaryContainer: string;
onPrimaryContainer: string;
inversePrimary: string;
secondaryContainer: string;
onSecondaryContainer: string;
tertiaryContainer: string;
onTertiaryContainer: string;
surface: string;
surfaceContainerLow: string;
surfaceContainer: string;
surfaceContainerHigh: string;
surfaceContainerHighest: string;
onSurface: string;
onSurfaceVariant: string;
outline: string;
outlineVariant: string;
inverseSurface: string;
inverseOnSurface: string;
error: string;
onError: string;
errorContainer: string;
onErrorContainer: string;
};
const ERROR = {
error: "#B3261E",
onError: "#FFFFFF",
errorContainer: "#F9DEDC",
onErrorContainer: "#410E0B",
};
export const PALETTES: Palette[] = [
{
key: "purple",
label: "Purple",
primary: "#6750A4",
onPrimary: "#FFFFFF",
primaryContainer: "#EADDFF",
onPrimaryContainer: "#21005D",
inversePrimary: "#D0BCFF",
secondaryContainer: "#E8DEF8",
onSecondaryContainer: "#1D192B",
tertiaryContainer: "#FFD8E4",
onTertiaryContainer: "#31111D",
surface: "#FEF7FF",
surfaceContainerLow: "#F7F2FA",
surfaceContainer: "#F3EDF7",
surfaceContainerHigh: "#ECE6F0",
surfaceContainerHighest: "#E6E0E9",
onSurface: "#1D1B20",
onSurfaceVariant: "#49454F",
outline: "#79747E",
outlineVariant: "#CAC4D0",
inverseSurface: "#322F35",
inverseOnSurface: "#F5EFF7",
...ERROR,
},
{
key: "blue",
label: "Blue",
primary: "#0B57D0",
onPrimary: "#FFFFFF",
primaryContainer: "#D3E3FD",
onPrimaryContainer: "#041E49",
inversePrimary: "#A8C7FA",
secondaryContainer: "#DCE2F9",
onSecondaryContainer: "#131C2B",
tertiaryContainer: "#FFD8EE",
onTertiaryContainer: "#2E1125",
surface: "#FAF9FD",
surfaceContainerLow: "#F3F3FA",
surfaceContainer: "#EEEDF3",
surfaceContainerHigh: "#E9E8EF",
surfaceContainerHighest: "#E3E2E6",
onSurface: "#1B1B1F",
onSurfaceVariant: "#44474E",
outline: "#74777F",
outlineVariant: "#C4C6D0",
inverseSurface: "#303034",
inverseOnSurface: "#F2F0F4",
...ERROR,
},
{
key: "green",
label: "Green",
primary: "#2E6A45",
onPrimary: "#FFFFFF",
primaryContainer: "#B0F1C2",
onPrimaryContainer: "#00210F",
inversePrimary: "#95D5A7",
secondaryContainer: "#D3E8D8",
onSecondaryContainer: "#102016",
tertiaryContainer: "#C2E8FF",
onTertiaryContainer: "#001E2C",
surface: "#F6FBF4",
surfaceContainerLow: "#F0F5EE",
surfaceContainer: "#EAF0E8",
surfaceContainerHigh: "#E4EAE2",
surfaceContainerHighest: "#DEE4DC",
onSurface: "#181D18",
onSurfaceVariant: "#414941",
outline: "#707972",
outlineVariant: "#BFC9C0",
inverseSurface: "#2D322D",
inverseOnSurface: "#EEF2EB",
...ERROR,
},
{
key: "coral",
label: "Coral",
primary: "#984061",
onPrimary: "#FFFFFF",
primaryContainer: "#FFD9E2",
onPrimaryContainer: "#3E001D",
inversePrimary: "#FFB0C8",
secondaryContainer: "#F6DDE4",
onSecondaryContainer: "#31101D",
tertiaryContainer: "#FFDBCA",
onTertiaryContainer: "#2C1600",
surface: "#FFF8F8",
surfaceContainerLow: "#FCF0F2",
surfaceContainer: "#F6EBED",
surfaceContainerHigh: "#F3E5E9",
surfaceContainerHighest: "#EEE0E3",
onSurface: "#201A1B",
onSurfaceVariant: "#524346",
outline: "#847377",
outlineVariant: "#D5C2C6",
inverseSurface: "#352F30",
inverseOnSurface: "#FAEEEF",
...ERROR,
},
{
key: "amber",
label: "Amber",
primary: "#8B5000",
onPrimary: "#FFFFFF",
primaryContainer: "#FFDCC2",
onPrimaryContainer: "#2C1600",
inversePrimary: "#FFB77C",
secondaryContainer: "#F6DFC8",
onSecondaryContainer: "#271905",
tertiaryContainer: "#D5EDC0",
onTertiaryContainer: "#0E2004",
surface: "#FFF8F5",
surfaceContainerLow: "#FCF1EA",
surfaceContainer: "#F7ECE4",
surfaceContainerHigh: "#F3E6DE",
surfaceContainerHighest: "#EDE0D8",
onSurface: "#211A14",
onSurfaceVariant: "#51443B",
outline: "#83746A",
outlineVariant: "#D6C3B6",
inverseSurface: "#362F28",
inverseOnSurface: "#FBEEE5",
...ERROR,
},
{
key: "teal",
label: "Teal",
primary: "#00696E",
onPrimary: "#FFFFFF",
primaryContainer: "#9CF1F6",
onPrimaryContainer: "#002022",
inversePrimary: "#80D5DA",
secondaryContainer: "#CCE8E9",
onSecondaryContainer: "#051F20",
tertiaryContainer: "#D2E4FF",
onTertiaryContainer: "#001C3B",
surface: "#F4FBFB",
surfaceContainerLow: "#EEF5F5",
surfaceContainer: "#E8EFEF",
surfaceContainerHigh: "#E2EAEA",
surfaceContainerHighest: "#DDE4E4",
onSurface: "#161D1D",
onSurfaceVariant: "#3F4948",
outline: "#6F7979",
outlineVariant: "#BEC8C8",
inverseSurface: "#2B3232",
inverseOnSurface: "#ECF2F2",
...ERROR,
},
{
key: "mono",
label: "Mono",
primary: "#4A4459",
onPrimary: "#FFFFFF",
primaryContainer: "#E6E0F0",
onPrimaryContainer: "#1A1626",
inversePrimary: "#CFC3E0",
secondaryContainer: "#E6E1E6",
onSecondaryContainer: "#1B1B1F",
tertiaryContainer: "#E9E0EA",
onTertiaryContainer: "#1E1A22",
surface: "#FCF8FD",
surfaceContainerLow: "#F5F1F6",
surfaceContainer: "#EFEBF0",
surfaceContainerHigh: "#E9E5EA",
surfaceContainerHighest: "#E4E0E5",
onSurface: "#1C1B1F",
onSurfaceVariant: "#48454E",
outline: "#79747E",
outlineVariant: "#CAC4D0",
inverseSurface: "#313033",
inverseOnSurface: "#F4EFF4",
...ERROR,
},
];
/* ---------- theme: the four expressive axes ---------- */
export type ShapeScale = "square" | "rounded" | "full";
export type FontKey = "roboto" | "robotoFlex" | "robotoSerif" | "system";
export type MotionScheme = "standard" | "expressive";
export type { Contrast };
export type Theme = {
dark: boolean;
/** the app follows the system setting; the canvas shows the mode chosen in `dark` */
bothModes: boolean;
contrast: Contrast;
shape: ShapeScale;
font: FontKey;
/** headings and labels take the heavier M3 Expressive "emphasized" styles */
emphasized: boolean;
motion: MotionScheme;
};
export const DEFAULT_THEME: Theme = { dark: false, bothModes: false, contrast: "standard", shape: "rounded", font: "roboto", emphasized: false, motion: "standard" };
/** a stored theme with any missing or unknown field replaced by its default */
export function normalizeTheme(t: Partial<Theme> | undefined): Theme {
const d = DEFAULT_THEME;
if (!t) return d;
return {
dark: typeof t.dark === "boolean" ? t.dark : d.dark,
bothModes: typeof t.bothModes === "boolean" ? t.bothModes : d.bothModes,
contrast: CONTRASTS.some((c) => c.key === t.contrast) ? (t.contrast as Contrast) : d.contrast,
shape: SHAPES.some((c) => c.key === t.shape) ? (t.shape as ShapeScale) : d.shape,
font: FONTS.some((c) => c.key === t.font) ? (t.font as FontKey) : d.font,
emphasized: typeof t.emphasized === "boolean" ? t.emphasized : d.emphasized,
motion: t.motion === "expressive" || t.motion === "standard" ? t.motion : d.motion,
};
}
export const SHAPES: { key: ShapeScale; label: string; icon: string }[] = [
{ key: "square", label: "Square", icon: "crop_square" },
{ key: "rounded", label: "Rounded", icon: "rounded_corner" },
{ key: "full", label: "Full", icon: "circle" },
];
export const FONTS: { key: FontKey; label: string; family: string; /** Google Fonts family to fetch, if any */ google?: string }[] = [
{ key: "roboto", label: "Roboto", family: "Roboto, system-ui, sans-serif" },
{ key: "robotoFlex", label: "Roboto Flex", family: "'Roboto Flex', Roboto, system-ui, sans-serif", google: "Roboto+Flex:wght@400;500;600;700" },
{ key: "robotoSerif", label: "Roboto Serif", family: "'Roboto Serif', Georgia, serif", google: "Roboto+Serif:wght@400;500;600;700" },
{ key: "system", label: "System", family: "system-ui, -apple-system, 'Segoe UI', sans-serif" },
];
/** The Noto Sans face that covers a language's script, spliced in behind the chosen
* face so CJK text renders the same on every OS. English needs none. */
export const LANG_FONT: Record<Lang, { family: string; google: string } | null> = {
ja: { family: "'Noto Sans JP'", google: "Noto+Sans+JP:wght@400;500;600;700" },
zh: { family: "'Noto Sans SC'", google: "Noto+Sans+SC:wght@400;500;600;700" },
ko: { family: "'Noto Sans KR'", google: "Noto+Sans+KR:wght@400;500;600;700" },
en: null,
};
/** a family list with the language's Noto face placed before the generic fallbacks */
const withLangFont = (family: string, lang?: Lang) => {
const extra = lang ? LANG_FONT[lang]?.family : undefined;
if (!extra) return family;
const parts = family.split(",").map((s) => s.trim());
const at = parts.findIndex((s) => /^(system-ui|-apple-system|Georgia|serif|sans-serif)$/.test(s));
parts.splice(at < 0 ? parts.length : at, 0, extra);
return parts.join(", ");
};
/** the chosen face, with the language's Noto face behind it; the system font is left to the device */
export const fontFamilyOf = (f: FontKey, lang?: Lang) => {
const family = FONTS.find((x) => x.key === f)?.family ?? FONTS[0].family;
return f === "system" ? family : withLangFont(family, lang);
};
/** the editor's own font: Roboto, then the language's Noto face */
export const uiFontFamily = (lang?: Lang) => withLangFont("Roboto, system-ui, sans-serif", lang);
export const CONTRASTS: { key: Contrast; label: string }[] = [
{ key: "standard", label: "Standard" },
{ key: "medium", label: "Medium" },
{ key: "high", label: "High" },
];
/** the shape scale that rendering helpers read outside React; the page sets it once per render */
let curShape: ShapeScale = "rounded";
export const setGlobalShape = (s: ShapeScale) => {
curShape = s;
};
export const getShape = () => curShape;
/** a default corner radius under the document's shape scale */
export function scaleR(r: number): number {
if (curShape === "square") return Math.round(r * 0.35);
if (curShape === "full") return Math.round(r * 1.6);
return r;
}
/** The scheme the document renders with. Hand-written presets and the author's
* fine-tuned custom scheme are light and standard contrast; dark mode and the
* other contrast levels are generated from the same seed. */
export function paletteOf(key: string, custom?: Palette | null, theme?: Theme): Palette {
const base = (key === "custom" && custom) || PALETTES.find((p) => p.key === key) || PALETTES[0];
if (!theme || (!theme.dark && theme.contrast === "standard")) return base;
const seed = base.seed ?? base.primary;
/* a preset's hue and chroma are deliberate (Mono is nearly grey), so they are kept as they are */
return { ...schemeFromSeed(seed, base.label, { dark: theme.dark, contrast: theme.contrast, keepChroma: base.key !== "custom" }), key: base.key };
}
/* ---------- contrast roles a component can take ---------- */
export type Variant = "filled" | "tonal" | "elevated" | "outlined" | "text";
export const VARIANTS: { key: Variant; label: string }[] = [
{ key: "filled", label: "Filled" },
{ key: "tonal", label: "Tonal" },
{ key: "elevated", label: "Elevated" },
{ key: "outlined", label: "Outlined" },
{ key: "text", label: "Text" },
];
export function variantStyle(v: Variant, p: Palette): CSSProperties {
switch (v) {
case "filled":
return { background: p.primary, color: p.onPrimary, border: "none" };
case "tonal":
return { background: p.secondaryContainer, color: p.onSecondaryContainer, border: "none" };
case "elevated":
return { background: p.surfaceContainerLow, color: p.primary, border: "none" };
case "outlined":
return { background: "transparent", color: p.primary, border: `1px solid ${p.outline}` };
case "text":
return { background: "transparent", color: p.primary, border: "none" };
}
}
export function variantShadow(v: Variant): string {
if (v === "elevated") return "0 1px 3px rgba(0,0,0,0.20), 0 4px 8px rgba(0,0,0,0.10)";
return "none";
}
/* ---------- component kinds ---------- */
export type Kind =
| "box"
| "button"
| "iconButton"
| "fab"
| "extendedFab"
| "chip"
| "topAppBar"
| "bottomNav"
| "navRail"
| "searchBar"
| "card"
| "listItem"
| "dialog"
| "snackbar"
| "textField"
| "switch"
| "checkbox"
| "slider"
| "text"
| "image"
| "divider"
| "loadingIndicator"
| "linearProgress"
| "circularProgress"
| "splitButton"
| "fabMenu"
| "toolbar"
| "tabs"
| "radio"
| "badge";
export type Axis = "x" | "y";
/** kinds that fuse into a run: buttons side by side, list items stacked */
export type ConnectSpec = { axis: Axis; outer: number; inner: number; family: string };
/** `presets` are quick picks shown as chips; values outside min..max are hidden */
export type SizeSpec = { min: number; max: number; step: number; icon: string; presets?: number[] };
export type Category = "actions" | "navigation" | "containment" | "inputs" | "content" | "progress";
export const CATEGORIES: { key: Category; label: string; icon: string }[] = [
{ key: "actions", label: "Actions", icon: "touch_app" },
{ key: "navigation", label: "Navigation", icon: "explore" },
{ key: "containment", label: "Containment", icon: "web_asset" },
{ key: "inputs", label: "Inputs", icon: "toggle_on" },
{ key: "content", label: "Content", icon: "notes" },
{ key: "progress", label: "Progress", icon: "progress_activity" },
];
export type KindSpec = {
label: string;
/** short Japanese noun used by the prompt generator */
noun: string;
category: Category;
paletteIcon: string;
/** intrinsic size; buttons measure their content, sized kinds use `size` */
w: number;
h: number;
radius: number;
hasVariant: boolean;
hasLabel: boolean;
hasSupporting: boolean;
hasIcon: boolean;
hasChecked?: boolean;
/** carries a list of icon + label entries (navigation bar, tabs, FAB menu, toolbar) */
hasTabs?: boolean;
/** second dimension (height) for free-form boxes */
size2?: SizeSpec;
hasFill?: boolean;
hasValue?: boolean;
hasWavy?: boolean;
hasContained?: boolean;
connect?: ConnectSpec;
size?: SizeSpec;
defLabel: string;
defIcon: string | null;
defSupporting?: string;
defIcon2?: string;
defSize?: number;
defVariant?: Variant;
};
export const KIND_SPEC: Record<Kind, KindSpec> = {
box: {
label: "Box",
noun: "ボックス",
category: "containment",
paletteIcon: "check_box_outline_blank",
w: PHONE_W,
h: 220,
radius: 28,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasChecked: true,
hasFill: true,
size: { min: 40, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
size2: { min: 24, max: PHONE_H, step: 4, icon: "height", presets: HEIGHT_PRESETS },
defLabel: "",
defIcon: null,
defSize: PHONE_W,
},
button: {
label: "Button",
noun: "ボタン",
category: "actions",
paletteIcon: "buttons_alt",
w: 128,
h: H,
radius: R_FULL,
hasVariant: true,
hasLabel: true,
hasSupporting: false,
hasIcon: true,
connect: { axis: "x", outer: R_FULL, inner: R_INNER, family: "button" },
size: { min: 64, max: PHONE_W, step: 4, icon: "width", presets: [HALF_W, CONTENT_W] },
defLabel: "ボタン",
defIcon: "add",
},
iconButton: {
label: "Icon Button",
noun: "アイコンボタン",
category: "actions",
paletteIcon: "radio_button_checked",
w: 48,
h: 48,
radius: 24,
hasVariant: true,
hasLabel: false,
hasSupporting: false,
hasIcon: true,
connect: { axis: "x", outer: 24, inner: R_INNER, family: "button" },
size: { min: 40, max: 96, step: 4, icon: "open_in_full", presets: [40, 48, 56, 96] },
defLabel: "",
defIcon: "favorite",
defSize: 48,
defVariant: "tonal",
},
fab: {
label: "FAB",
noun: "FAB(フローティングボタン)",
category: "actions",
paletteIcon: "add_circle",
w: 56,
h: 56,
radius: 16,
hasVariant: true,
hasLabel: false,
hasSupporting: false,
hasIcon: true,
size: { min: 40, max: 128, step: 4, icon: "open_in_full", presets: [40, 56, 96] },
defLabel: "",
defIcon: "edit",
defSize: 56,
defVariant: "tonal",
},
extendedFab: {
label: "Extended FAB",
noun: "拡張 FAB",
category: "actions",
paletteIcon: "add_box",
w: 0,
h: 56,
radius: 16,
hasVariant: true,
hasLabel: true,
hasSupporting: false,
hasIcon: true,
defLabel: "作成",
defIcon: "edit",
defVariant: "tonal",
},
chip: {
label: "Chip",
noun: "チップ",
category: "actions",
paletteIcon: "label",
w: 0,
h: 32,
radius: 8,
hasVariant: true,
hasLabel: true,
hasSupporting: false,
hasIcon: true,
hasChecked: true,
connect: { axis: "x", outer: 16, inner: 4, family: "chip" },
defLabel: "チップ",
defIcon: null,
defVariant: "outlined",
},
topAppBar: {
label: "Top App Bar",
noun: "トップアプリバー",
category: "navigation",
paletteIcon: "toolbar",
w: PHONE_W,
h: 64 + STATUS_BAR_H,
radius: 0,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: true,
size: { min: 200, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "タイトル",
defIcon: "menu",
defIcon2: "more_vert",
defSize: PHONE_W,
},
bottomNav: {
label: "Navigation Bar",
noun: "ナビゲーションバー",
category: "navigation",
paletteIcon: "bottom_navigation",
w: PHONE_W,
h: 80 + NAV_BAR_H,
radius: 0,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasTabs: true,
size: { min: 200, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "",
defIcon: null,
defSize: PHONE_W,
},
navRail: {
label: "Navigation Rail",
noun: "ナビゲーションレール",
category: "navigation",
paletteIcon: "side_navigation",
w: RAIL_W,
h: PHONE_H,
radius: 0,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasTabs: true,
size2: { min: 200, max: PHONE_H, step: 4, icon: "height", presets: HEIGHT_PRESETS },
defLabel: "",
defIcon: null,
},
searchBar: {
label: "Search Bar",
noun: "検索バー",
category: "navigation",
paletteIcon: "search",
w: CONTENT_W,
h: 56,
radius: 28,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: true,
size: { min: 200, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "検索",
defIcon: "search",
defIcon2: "mic",
defSize: CONTENT_W,
},
card: {
label: "Card",
noun: "カード",
category: "containment",
paletteIcon: "web_asset",
w: CONTENT_W,
h: 223,
radius: 20,
hasVariant: true,
hasLabel: true,
hasSupporting: true,
hasIcon: true,
size: { min: 160, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
size2: { min: 96, max: PHONE_H, step: 4, icon: "height", presets: [120, 188, 280] },
hasFill: true,
defLabel: "カードの見出し",
defIcon: "image",
defSupporting: "補足テキストがここに入ります。",
defSize: CONTENT_W,
defVariant: "tonal",
},
listItem: {
label: "List Item",
noun: "リスト項目",
category: "containment",
paletteIcon: "list",
w: CONTENT_W,
h: 72,
radius: R_FULL,
hasVariant: false,
hasLabel: true,
hasSupporting: true,
hasIcon: true,
hasFill: true,
connect: { axis: "y", outer: R_FULL, inner: R_INNER, family: "list" },
size: { min: 200, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "リスト項目",
defIcon: "person",
defSupporting: "サブテキスト",
defIcon2: "chevron_right",
defSize: CONTENT_W,
},
dialog: {
label: "Dialog",
noun: "ダイアログ",
category: "containment",
paletteIcon: "chat_bubble",
w: 312,
h: 220,
radius: 28,
hasVariant: false,
hasLabel: true,
hasSupporting: true,
hasIcon: true,
defLabel: "確認",
defIcon: "info",
defSupporting: "この操作を実行しますか?",
},
snackbar: {
label: "Snackbar",
noun: "スナックバー",
category: "containment",
paletteIcon: "call_to_action",
w: 344,
h: 48,
radius: 8,
hasVariant: false,
hasLabel: true,
hasSupporting: true,
hasIcon: false,
defLabel: "保存しました",
defIcon: null,
defSupporting: "元に戻す",
},
textField: {
label: "Text Field",
noun: "テキスト入力",
category: "inputs",
paletteIcon: "text_fields",
w: CONTENT_W,
h: 56,
radius: 16,
hasVariant: true,
hasLabel: true,
hasSupporting: true,
hasIcon: true,
size: { min: 160, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "ラベル",
defIcon: "search",
defSupporting: "",
defSize: CONTENT_W,
defVariant: "outlined",
},
switch: {
label: "Switch",
noun: "スイッチ",
category: "inputs",
paletteIcon: "toggle_on",
w: 160,
h: 48,
radius: 16,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: false,
hasChecked: true,
size: { min: 120, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "通知",
defIcon: null,
},
checkbox: {
label: "Checkbox",
noun: "チェックボックス",
category: "inputs",
paletteIcon: "check_box",
w: 0,
h: 40,
radius: 4,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: false,
hasChecked: true,
defLabel: "同意する",
defIcon: null,
},
slider: {
label: "Slider",
noun: "スライダー",
category: "inputs",
paletteIcon: "sliders",
w: CONTENT_W,
h: 44,
radius: 22,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasValue: true,
size: { min: 120, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "",
defIcon: null,
defSize: CONTENT_W,
},
text: {
label: "Text",
noun: "テキスト",
category: "content",
paletteIcon: "title",
w: 0,
h: 40,
radius: 0,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: false,
size: { min: 12, max: 57, step: 1, icon: "format_size", presets: [14, 16, 22, 28, 32, 45, 57] },
defLabel: "見出し",
defIcon: null,
defSize: 28,
},
image: {
label: "Image",
noun: "画像",
category: "content",
paletteIcon: "image",
w: 200,
h: 200,
radius: 20,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: true,
size: { min: 48, max: PHONE_W, step: 4, icon: "open_in_full", presets: [96, HALF_W, CONTENT_W, PHONE_W] },
defLabel: "",
defIcon: "image",
defSize: 200,
},
divider: {
label: "Divider",
noun: "区切り線",
category: "content",
paletteIcon: "horizontal_rule",
w: CONTENT_W,
h: 16,
radius: 0,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
size: { min: 40, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "",
defIcon: null,
defSize: CONTENT_W,
},
loadingIndicator: {
label: "Loading Indicator",
noun: "ローディングインジケータ",
category: "progress",
paletteIcon: "motion_blur",
w: 48,
h: 48,
radius: 24,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasContained: true,
size: { min: 32, max: 128, step: 4, icon: "open_in_full", presets: [32, 48, 64, 96] },
defLabel: "",
defIcon: null,
defSize: 48,
},
linearProgress: {
label: "Linear Progress",
noun: "リニアプログレス",
category: "progress",
paletteIcon: "linear_scale",
w: CONTENT_W,
h: 24,
radius: 12,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasValue: true,
hasWavy: true,
size: { min: 120, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "",
defIcon: null,
defSize: CONTENT_W,
},
circularProgress: {
label: "Circular Progress",
noun: "サーキュラープログレス",
category: "progress",
paletteIcon: "progress_activity",
w: 48,
h: 48,
radius: 24,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasValue: true,
hasWavy: true,
size: { min: 24, max: 120, step: 4, icon: "open_in_full", presets: [24, 40, 48, 64] },
defLabel: "",
defIcon: null,
defSize: 48,
},
splitButton: {
label: "Split Button",
noun: "スプリットボタン",
category: "actions",
paletteIcon: "splitscreen_right",
w: 0,
h: H,
radius: R_FULL,
hasVariant: true,
hasLabel: true,
hasSupporting: false,
hasIcon: true,
defLabel: "送信",
defIcon: "send",
defVariant: "filled",
},
fabMenu: {
label: "FAB Menu",
noun: "FAB メニュー",
category: "actions",
paletteIcon: "add_circle",
w: 220,
h: 56,
radius: 16,
hasVariant: true,
hasLabel: false,
hasSupporting: false,
hasIcon: true,
hasTabs: true,
size: { min: 160, max: CONTENT_W, step: 4, icon: "width", presets: [220, HALF_W, CONTENT_W] },
defLabel: "",
defIcon: "close",
defSize: 220,
defVariant: "filled",
},
toolbar: {
label: "Toolbar",
noun: "ツールバー",
category: "navigation",
paletteIcon: "toolbar",
w: 0,
h: 64,
radius: 32,
hasVariant: true,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasTabs: true,
defLabel: "",
defIcon: null,
defVariant: "tonal",
},
tabs: {
label: "Tabs",
noun: "タブ",
category: "navigation",
paletteIcon: "tab",
w: PHONE_W,
h: 48,
radius: 0,
hasVariant: false,
hasLabel: false,
hasSupporting: false,
hasIcon: false,
hasTabs: true,
size: { min: 200, max: PHONE_W, step: 4, icon: "width", presets: WIDTH_PRESETS },
defLabel: "",
defIcon: null,
defSize: PHONE_W,
},
radio: {
label: "Radio Button",
noun: "ラジオボタン",
category: "inputs",
paletteIcon: "radio_button_checked",
w: 0,
h: 40,
radius: 20,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: false,
hasChecked: true,
defLabel: "選択肢",
defIcon: null,
},
badge: {
label: "Badge",
noun: "バッジ",
category: "content",
paletteIcon: "notifications_unread",
w: 0,
h: 16,
radius: 8,
hasVariant: false,
hasLabel: true,
hasSupporting: false,
hasIcon: false,
defLabel: "3",
defIcon: null,
},
};
export const KIND_ORDER: Kind[] = [
"button",
"iconButton",
"fab",
"extendedFab",
"splitButton",
"fabMenu",
"chip",
"topAppBar",
"bottomNav",
"navRail",
"toolbar",
"tabs",
"searchBar",
"card",
"listItem",
"box",
"dialog",
"snackbar",
"textField",
"switch",
"checkbox",
"radio",
"slider",
"text",
"image",
"badge",
"divider",
"loadingIndicator",
"linearProgress",
"circularProgress",
];
/* ---------- screen data ---------- */
export type NavTab = { icon: string; label: string };
export type Item = {
id: string;
kind: Kind;
label: string;
icon: string | null;
icon2?: string | null;
variant: Variant;
supporting?: string;
size?: number;
radiusTop?: number;
radiusBottom?: number;
/** boxes only: each corner on its own, when the pairs above are not enough */
corners?: Radii;
tabs?: NavTab[];
/** navigation bars, rails and tab rows: index of the selected destination (0 when unset) */
selected?: number;
/** list items: a switch at the trailing end instead of an icon; `checked` is its state */
switch?: boolean;
/** cards: no image area at the top; `src` puts a picture in it */
noImage?: boolean;
/** on/off state for switches, checkboxes and chips */
checked?: boolean;
/** a switch whose handle stays plain when on, without the check icon */
noCheck?: boolean;
/** 0..100 for sliders and determinate progress; undefined = indeterminate */
value?: number;
wavy?: boolean;
contained?: boolean;
/** free text the author writes about what this part does */
note?: string;
/** what `note` said before the AI rewrote it, so the rewrite can be undone */
noteHistory?: string[];
bold?: boolean;
/** height for free-form boxes */
size2?: number;
/** palette token used as background (boxes, list items) */
fill?: ColorToken;
/** background behind a list item's leading icon; "none" draws the icon bare */
iconFill?: ColorToken | "none";
/** data URL of a user-picked image */
src?: string;
/** tap navigation to another frame */
action?: Action;
/** per-slot tap navigation for bars: "icon" / "icon2" on a top app bar, "tab:N" on a navigation bar */
actions?: Record<string, Action>;
/** the look a toggle button takes once tapped; undefined = not a toggle */
toggle?: ToggleLook;
};
export type ToggleLook = { icon?: string | null; variant?: Variant; label?: string };
/** kinds that can act as a toggle button in the preview */
export const TOGGLEABLE: Kind[] = ["button", "iconButton", "fab", "extendedFab"];
/** target id that pops the preview stack instead of opening a frame */
export const BACK_TARGET = "back";
/** a swipe on a frame: the finger's direction */
export type SwipeDir = "left" | "right" | "up" | "down";
export const SWIPE_DIRS: { key: SwipeDir; icon: string; transition: Transition }[] = [
{ key: "left", icon: "swipe_left", transition: "slide" },
{ key: "right", icon: "swipe_right", transition: "slideLeft" },
{ key: "up", icon: "swipe_up", transition: "slideUp" },
{ key: "down", icon: "swipe_down", transition: "slideDown" },
];
/** how a sliding transition moves: the axis, where the new screen enters from and
* where the old one parks, as fractions of the screen */
export const SLIDE_SPEC: Partial<Record<Transition, { axis: "x" | "y"; enter: number; exit: number }>> = {
slide: { axis: "x", enter: 1, exit: -0.3 },
slideLeft: { axis: "x", enter: -1, exit: 0.3 },
slideUp: { axis: "y", enter: 1, exit: -0.3 },
slideDown: { axis: "y", enter: -1, exit: 0.3 },
};
export type Transition = "slide" | "slideLeft" | "slideUp" | "slideDown" | "fade" | "expand" | "none";
export type Action = { to: string; transition: Transition };
export const TRANSITIONS: { key: Transition; label: string; icon: string }[] = [
{ key: "slide", label: "Slide from right", icon: "arrow_back" },
{ key: "slideLeft", label: "Slide from left", icon: "arrow_forward" },
{ key: "slideUp", label: "Slide from bottom", icon: "arrow_upward" },
{ key: "slideDown", label: "Slide from top", icon: "arrow_downward" },
{ key: "fade", label: "Fade", icon: "blur_on" },
{ key: "expand", label: "Expand", icon: "open_in_full" },
{ key: "none", label: "None", icon: "block" },
];
/** slots on a bar that can each carry their own tap action */
export function actionSlotsOf(it: Item): IconSlot[] {
if (it.kind === "topAppBar" || it.kind === "bottomNav" || it.kind === "navRail" || it.kind === "toolbar") return iconSlotsOf(it).filter((s) => !!s.value);
if (it.kind === "fabMenu") return (it.tabs ?? []).map((t, i) => ({ key: `tab:${i}`, label: t.label || `${i + 1}`, value: t.icon || null }));
if (it.kind === "tabs") return (it.tabs ?? []).map((t, i) => ({ key: `tab:${i}`, label: t.label || `${i + 1}`, value: null }));
return [];
}
/** the icon a toggle button shows when on: an explicit null means none */
export const toggleIcon = (it: Item): string | null => (it.toggle && it.toggle.icon !== undefined ? it.toggle.icon : it.icon);
/** a free group down to one part is just that part again */
export function collapseFree(g: Group, widths: Record<string, number>): Group {
if (!g.free || g.items.length !== 1) return g;
const pl = layoutOf(g, widths)[0];
return { id: g.id, x: pl.x, y: pl.y, axis: connectSpecOf(g.items[0])?.axis ?? "x", items: g.items };
}
/** every navigation an item carries: its own action plus per-slot ones */
export function actionsOf(it: Item): { slot: string; action: Action }[] {
const out: { slot: string; action: Action }[] = [];
if (it.action) out.push({ slot: "", action: it.action });
for (const [slot, action] of Object.entries(it.actions ?? {})) if (action) out.push({ slot, action });
return out;
}
/** kinds a user can tap in the preview */
export const TAPPABLE: Kind[] = ["button", "iconButton", "fab", "extendedFab", "chip", "listItem", "card", "image", "text", "splitButton", "radio"];
/** palette roles a user may pick as a background */
export type ColorToken =
| "surface"
| "surfaceContainerLow"
| "surfaceContainer"
| "surfaceContainerHigh"
| "surfaceContainerHighest"
| "primaryContainer"
| "secondaryContainer"
| "tertiaryContainer"
| "primary"
| "inverseSurface";
export const COLOR_TOKENS: { key: ColorToken; label: string }[] = [
{ key: "surface", label: "Surface" },
{ key: "surfaceContainerLow", label: "Container low" },
{ key: "surfaceContainer", label: "Container" },
{ key: "surfaceContainerHigh", label: "Container high" },
{ key: "surfaceContainerHighest", label: "Container highest" },
{ key: "primaryContainer", label: "Primary container" },
{ key: "secondaryContainer", label: "Secondary container" },
{ key: "tertiaryContainer", label: "Tertiary container" },
{ key: "primary", label: "Primary" },
{ key: "inverseSurface", label: "Inverse surface" },
];
/** readable foreground for a chosen background token */
/** the background a card draws when no token is set: it follows the variant */
export const cardFillOf = (it: Item): ColorToken => it.fill ?? (it.variant === "outlined" ? "surface" : it.variant === "elevated" ? "surfaceContainerLow" : "surfaceContainerHighest");
export function onToken(t: ColorToken, p: Palette): string {
switch (t) {
case "primary":
return p.onPrimary;
case "primaryContainer":
return p.onPrimaryContainer;
case "secondaryContainer":
return p.onSecondaryContainer;
case "tertiaryContainer":
return p.onTertiaryContainer;
case "inverseSurface":
return p.inverseOnSurface;
default:
return p.onSurface;
}
}
export type Frame = {
id: string;
name: string;
x: number;
y: number;
/** dimensions are optional so documents saved before desktop frames remain phone-sized */
w?: number;
h?: number;
bg?: ColorToken;
/** what this screen is for, in the author's words; goes into the prompt */
note?: string;
/** what `note` said before the AI rewrote it */
noteHistory?: string[];
/** frame ids reached by swiping in each direction */
swipe?: Partial<Record<SwipeDir, string>>;
};
export type FramePreset = "phone" | "desktop";
export const frameSizeOf = (f: Frame) => ({ w: f.w ?? PHONE_W, h: f.h ?? PHONE_H });
export const isPhoneFrame = (f: Frame) => {
const { w, h } = frameSizeOf(f);
return w === PHONE_W && h === PHONE_H;
};
export const framePresetOf = (f: Frame): FramePreset => (isPhoneFrame(f) ? "phone" : "desktop");
export const framePresetPatch = (preset: FramePreset): Pick<Frame, "w" | "h"> =>
preset === "desktop" ? { w: DESKTOP_W, h: DESKTOP_H } : { w: undefined, h: undefined };
export const frameRect = (f: Frame) => {
const { w, h } = frameSizeOf(f);
return { l: f.x, t: f.y, r: f.x + w, b: f.y + h };
};
/** the corner radius of a screen: a phone's rounded glass, a flatter window for the desktop */
export const frameRadius = (f: Frame) => (isPhoneFrame(f) ? PHONE_R : DESKTOP_R);
/** parts that span the screen edge to edge and follow its width when it changes */
export const FULL_WIDTH: Kind[] = ["topAppBar", "bottomNav", "tabs"];
/** a part no taller than the screen it is placed on: a box or a rail sized to a phone shrinks to a shorter screen */
export function fitHeight(it: Item, screenH: number): Item {
const spec = KIND_SPEC[it.kind];
if (!spec.size2 && it.kind !== "navRail") return it;
const h = it.size2 ?? spec.h;
return h > screenH ? { ...it, size2: screenH } : it;
}
/** A part carried from one screen size to another: edge-to-edge parts take the new
* width, a part sized to the old content or screen width takes the new one, and a
* box as tall as the old screen takes the new height. A card or an image keeps its
* size, since its height follows its width. Nothing ends up wider than the new
* content area. */
export function carryItemSize(it: Item, from: { w: number; h: number }, to: { w: number; h: number }): Item {
const spec = KIND_SPEC[it.kind];
const patch: Partial<Item> = {};
const keepsShape = it.kind === "card" || it.kind === "image";
if (spec.size && (spec.size.icon === "width" || keepsShape)) {
/* only a size that is a width; a text size or an icon button's square are left alone */
const cur = it.size ?? spec.defSize ?? spec.w;
if (FULL_WIDTH.includes(it.kind)) {
/* a bar the author narrowed on purpose stays narrow; one that spanned the screen still does */
if (cur === from.w || cur > to.w) patch.size = to.w;
} else if (keepsShape) {
if (cur > contentWidth(to.w)) patch.size = contentWidth(to.w);
} else if (cur === from.w) patch.size = to.w;
else if (cur === contentWidth(from.w)) patch.size = contentWidth(to.w);
else if (cur === halfWidth(from.w)) patch.size = halfWidth(to.w);
else if (cur > to.w) patch.size = to.w;
else if (cur > contentWidth(to.w) && it.kind !== "box") patch.size = contentWidth(to.w);
}
if ((it.kind === "box" || it.kind === "navRail") && (it.size2 ?? spec.h) === from.h) patch.size2 = to.h;
return Object.keys(patch).length ? { ...it, ...patch } : it;
}
export type Placed = { item: Item; index: number; x: number; y: number; w: number; h: number };
/** where each part of a run sits in world space: a connected run lays its parts
* out along its axis, a free group keeps the offsets it was grouped with */
export function layoutOf(g: Group, widths: Record<string, number>): Placed[] {
const out: Placed[] = [];
let off = 0;
g.items.forEach((it, index) => {
const sz = sizeOf(it, widths);
if (g.free) {
const o = g.pos?.[it.id] ?? { x: 0, y: 0 };
out.push({ item: it, index, x: g.x + o.x, y: g.y + o.y, w: sz.w, h: sz.h });
return;
}
out.push({ item: it, index, x: g.axis === "x" ? g.x + off : g.x, y: g.axis === "x" ? g.y : g.y + off, w: sz.w, h: sz.h });
off += (g.axis === "x" ? sz.w : sz.h) + GAP;
});
return out;
}
/** world-space bounds of a whole run */
export function groupBounds(g: Group, widths: Record<string, number>) {
let l = g.x;
let t = g.y;
let r = g.x;
let b = g.y;
for (const pl of layoutOf(g, widths)) {
l = Math.min(l, pl.x);
t = Math.min(t, pl.y);
r = Math.max(r, pl.x + pl.w);
b = Math.max(b, pl.y + pl.h);
}
return { l, t, r, b };
}
/** A free group written as the runs it holds: parts of one family that still sit
* one GAP apart along their axis stay a connected run, everything else is a run
* of one. Layout logic, the prompt and ungrouping all see the same runs, in the
* group's own order (later = drawn on top), which is what the layers panel edits. */
export function explodeGroup(g: Group, widths: Record<string, number>): Group[] {
if (!g.free) return [g];
const placed = [...layoutOf(g, widths)].sort((a, b) => a.y - b.y || a.x - b.x);
const rank = new Map(g.items.map((it, i) => [it.id, i]));
const used = new Set<string>();
const out: Group[] = [];
const near = (a: number, b: number) => Math.abs(a - b) <= 3;
for (const start of placed) {
if (used.has(start.item.id)) continue;
used.add(start.item.id);
const run = [start];
const axis = connectSpecOf(start.item)?.axis ?? "x";
let last = start;
for (;;) {
const next = placed.find(
(q) =>
!used.has(q.item.id) &&
canJoin(last.item, q.item) &&
(axis === "x" ? near(q.y, last.y) && near(q.x, last.x + last.w + GAP) : near(q.x, last.x) && near(q.y, last.y + last.h + GAP)),
);
if (!next) break;
used.add(next.item.id);
run.push(next);
last = next;
}
/* named after the member the group lists first, so the name survives a reorder */
const anchor = run.reduce((a, b) => ((rank.get(a.item.id) ?? 0) <= (rank.get(b.item.id) ?? 0) ? a : b));
out.push({ id: `${g.id}:${anchor.item.id}`, x: start.x, y: start.y, axis, items: run.map((r) => r.item) });
}
const first = (r: Group) => Math.min(...r.items.map((it) => rank.get(it.id) ?? 0));
return out.sort((a, b) => first(a) - first(b));
}
/** corners of one part of a run: round outside, small where it meets a neighbour */
export function runCorners(axis: Axis, first: boolean, last: boolean, outer: number, inner: number): Radii {
const a = first ? outer : inner;
const b = last ? outer : inner;
return axis === "x" ? { tl: a, bl: a, tr: b, br: b } : { tl: a, tr: a, bl: b, br: b };
}
/** the corner radii of every part in a free group, with its hidden runs kept connected */
export function freeRadii(g: Group, widths: Record<string, number>): Map<string, Radii> {
const out = new Map<string, Radii>();
for (const run of explodeGroup(g, widths)) {
const n = run.items.length;
run.items.forEach((it, i) => {
const c = connectSpecOf(it);
out.set(it.id, c ? runCorners(run.axis, i === 0, i === n - 1, c.outer, c.inner) : baseRadii(it));
});
}
return out;
}
/** a run belongs to the frame that contains its centre */
export function frameOfGroup(g: Group, frames: Frame[], widths: Record<string, number>): Frame | undefined {
const bb = groupBounds(g, widths);
const cx = (bb.l + bb.r) / 2;
const cy = (bb.t + bb.b) / 2;
return frames.find((f) => {
const fr = frameRect(f);
return cx >= fr.l && cx <= fr.r && cy >= fr.t && cy <= fr.b;
});
}
export const groupsInFrame = (groups: Group[], f: Frame, frames: Frame[], widths: Record<string, number>) =>
groups.filter((g) => frameOfGroup(g, frames, widths)?.id === f.id);
export type Group = {
id: string;
x: number;
y: number;
axis: Axis;
items: Item[];
/** a hand-made group: parts keep their own offsets (in `pos`) and move as one layer */
free?: boolean;
pos?: Record<string, { x: number; y: number }>;
};
export type FrameMode = "blank" | "phone";
/** where the generated prompt asks for the app to be built */
export type Platform = "android" | "web";
export const DEFAULT_PLATFORM: Platform = "android";
export const isPlatform = (v: unknown): v is Platform => v === "android" || v === "web";
/** The target the prompt assumes when the author has not picked one: the web as
* soon as a desktop screen exists, Android otherwise. */
export const defaultPlatformOf = (frames: Frame[], mode: FrameMode): Platform => (mode === "phone" && frames.some((f) => !isPhoneFrame(f)) ? "web" : DEFAULT_PLATFORM);
export type Doc = {
groups: Group[];
frames: Frame[];
paletteKey: string;
/** the author's own scheme, used when paletteKey is "custom" */
customPalette?: Palette;
/** the app should take its colors from the user's wallpaper (Material You) */
dynamicColor?: boolean;
frame: FrameMode;
/** the implementation target the prompt names; Android unless the author picks the web */
platform?: Platform;
title: string;
brief: string;
/** the prompt as the author rewrote it by hand; undefined means the generated one */
promptEdit?: string;
/** shape, type, motion and the light / dark and contrast switches */
theme?: Theme;
};
export const defaultTabs = (): NavTab[] => NAV_TABS[getLang()].map((t) => ({ ...t }));
const TOOLBAR_ICONS = ["format_bold", "format_italic", "format_underlined", "attach_file", "format_color_text", "more_vert"];
/** the entries a kind starts with, also used to fill in rows the author adds */
export function defaultTabsFor(kind: Kind): NavTab[] {
switch (kind) {
case "tabs":
return TAB_LABELS[getLang()].map((label) => ({ icon: "", label }));
case "fabMenu":
return FAB_MENU_TABS[getLang()].map((t) => ({ ...t }));
case "toolbar":
return TOOLBAR_ICONS.map((icon) => ({ icon, label: "" }));
default:
return defaultTabs();
}
}
export function makeItem(kind: Kind): Item {
const s = KIND_SPEC[kind];
const text = KIND_TEXT[getLang()][kind];
const it: Item = {
id: uid(),
kind,
label: text?.label ?? s.defLabel,
icon: s.defIcon,
variant: s.defVariant ?? "filled",
};
if (s.defSupporting !== undefined) it.supporting = text?.supporting ?? s.defSupporting;
if (s.defIcon2 !== undefined) it.icon2 = s.defIcon2;
if (s.defSize !== undefined) it.size = s.defSize;
if (s.hasChecked) it.checked = kind !== "chip" && kind !== "box";
if (kind === "box") {
it.size2 = 220;
it.radiusTop = 28;
it.radiusBottom = 28;
it.fill = "surfaceContainerHigh";
}
if (kind === "slider") it.value = 40;
if (kind === "bottomNav") {
it.tabs = defaultTabs();
it.radiusTop = 0;
it.radiusBottom = 0;
}
if (kind === "navRail") it.tabs = defaultTabs();
if (kind === "tabs" || kind === "fabMenu") it.tabs = defaultTabsFor(kind);
if (kind === "toolbar") it.tabs = defaultTabsFor(kind).slice(0, 4);
return it;
}
/** Content-sized kinds are measured in the DOM; the rest derive from spec + size. */
export const MEASURED: Kind[] = ["button", "extendedFab", "chip", "switch", "checkbox", "text", "splitButton", "radio", "badge"];
export function sizeOf(it: Item, widths: Record<string, number>) {
const s = KIND_SPEC[it.kind];
const n = it.size ?? s.defSize ?? s.w;
switch (it.kind) {
case "switch":
case "button":
return { w: it.size ?? widths[it.id] ?? s.w, h: s.h };
case "extendedFab":
case "chip":
case "checkbox":
case "splitButton":
case "radio":
return { w: widths[it.id] ?? 128, h: s.h };
case "badge":
return { w: widths[it.id] ?? 16, h: it.label.trim() ? s.h : 6 };
case "fabMenu":
return { w: n, h: 56 + (it.tabs?.length ?? 0) * (FAB_MENU_ITEM_H + FAB_MENU_GAP) };
case "toolbar":
return { w: toolbarWidth(it), h: s.h };
case "tabs":
return { w: n, h: s.h };
case "text":
return { w: widths[it.id] ?? 120, h: Math.round(n * 1.3) };
case "iconButton":
case "fab":
case "circularProgress":
case "loadingIndicator":
case "image":
return { w: n, h: n };
case "topAppBar":
/* the status-bar inset belongs to a phone: a bar wider than one has no status bar above it.
* (An Android tablet does; the canvas leaves that to the prompt.) */
return { w: n, h: 64 + (n > PHONE_W ? 0 : STATUS_BAR_H) };
case "searchBar":
case "bottomNav":
case "listItem":
case "textField":
case "slider":
case "linearProgress":
case "divider":
return { w: n, h: s.h };
case "card":
return { w: n, h: it.size2 ?? Math.round(n * 0.5875) };
case "box":
return { w: n, h: it.size2 ?? s.h };
case "navRail":
return { w: s.w, h: it.size2 ?? s.h };
default:
return { w: s.w, h: s.h };
}
}
/** Corners for a part that is not part of a connected run. Defaults follow the
* document's shape scale; a radius the author typed in is kept as is. */
export function baseRadii(it: Item): Radii {
const s = KIND_SPEC[it.kind];
switch (it.kind) {
case "box":
if (it.corners) return { ...it.corners };
// falls through
case "bottomNav":
case "topAppBar":
case "tabs": {
const t = it.radiusTop ?? 0;
const b = it.radiusBottom ?? 0;
return { tl: t, tr: t, bl: b, br: b };
}
case "navRail": {
/* a rail's corners are its left and right sides: radiusTop is the left pair, radiusBottom the right */
const l = it.radiusTop ?? 0;
const r = it.radiusBottom ?? 0;
return { tl: l, bl: l, tr: r, br: r };
}
case "fab":
return uniformRadii(scaleR(Math.round((it.size ?? 56) * 0.28)));
case "fabMenu":
return uniformRadii(0);
case "iconButton":
return uniformRadii(scaleR((it.size ?? 48) / 2));
case "circularProgress":
case "loadingIndicator":
return uniformRadii((it.size ?? 48) / 2);
case "image":
case "card":
return uniformRadii(it.radiusTop ?? scaleR(s.radius));
case "badge":
case "radio":
case "splitButton":
return uniformRadii(s.radius);
default:
return uniformRadii(scaleR(s.radius));
}
}
export const FAB_MENU_ITEM_H = 56;
export const FAB_MENU_GAP = 8;
/** a toolbar hugs its icon buttons: 48dp each with 4dp between, 8dp at the ends */
export const toolbarWidth = (it: Item) => {
const n = Math.max(1, it.tabs?.length ?? 0);
return 16 + n * 48 + (n - 1) * 4;
};
export const connectSpecOf = (it: Item): ConnectSpec | undefined => {
const c = KIND_SPEC[it.kind].connect;
return c && { ...c, outer: scaleR(c.outer), inner: scaleR(c.inner) };
};
export const connectable = (it: Item) => !!KIND_SPEC[it.kind].connect;
/** two parts fuse when they share an axis and a family (buttons and icon buttons mix) */
export const canJoin = (a: Item, b: Item) => {
const sa = connectSpecOf(a);
const sb = connectSpecOf(b);
return !!sa && !!sb && sa.axis === sb.axis && sa.family === sb.family;
};
/* ---------- icon slots ---------- */
export type IconSlot = { key: string; label: string; value: string | null };
export function iconSlotsOf(it: Item): IconSlot[] {
switch (it.kind) {
case "listItem":
case "topAppBar":
case "searchBar":
return [
{ key: "icon", label: t("leading"), value: it.icon },
{ key: "icon2", label: t("trailing"), value: it.icon2 ?? null },
];
case "bottomNav":
case "navRail":
case "toolbar":
return (it.tabs ?? []).map((t, i) => ({
key: `tab:${i}`,
label: `${i + 1}`,
value: t.icon || null,
}));
case "fabMenu":
return [
{ key: "icon", label: t("icon"), value: it.icon },
...(it.tabs ?? []).map((t, i) => ({ key: `tab:${i}`, label: `${i + 1}`, value: t.icon || null })),
];
default:
return KIND_SPEC[it.kind].hasIcon
? [{ key: "icon", label: t("icon"), value: it.icon }]
: [];
}
}
export function setIconSlot(it: Item, key: string, v: string | null): Partial<Item> {
if (key === "icon") return { icon: v };
if (key === "icon2") return { icon2: v };
if (key === "toggle") return { toggle: { ...(it.toggle ?? {}), icon: v } };
if (key.startsWith("tab:")) {
const i = Number(key.slice(4));
const tabs = (it.tabs ?? []).map((t, j) => (j === i ? { ...t, icon: v ?? "" } : t));
return { tabs };
}
return {};
}