// @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 | 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 = { 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 = { 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; /** 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> = { 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): 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>; }; 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 => 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 = {}; 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): 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) { 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): 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(); 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): Map { const out = new Map(); 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): 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) => 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; }; 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) { 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 { 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 {}; }