Files
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

1297 lines
46 KiB
TypeScript

// @ts-nocheck — vendored, see PROVENANCE.md
"use client";
import { useEffect, useRef, useState } from "react";
import {
Action,
BACK_TARGET,
CONTENT_W,
Frame,
FramePreset,
HALF_W,
Item,
KIND_SPEC,
PHONE_H,
PHONE_W,
Kind,
NavTab,
Palette,
SWIPE_DIRS,
SwipeDir,
TAPPABLE,
TOGGLEABLE,
TRANSITIONS,
Transition,
VARIANTS,
Variant,
actionSlotsOf,
contentWidth,
defaultTabsFor,
framePresetOf,
cardFillOf,
frameSizeOf,
halfWidth,
isPhoneFrame,
toggleIcon,
iconSlotsOf,
setIconSlot,
variantStyle,
} from "../lib/tokens";
import { IconPicker } from "./IconPicker";
import { Icon } from "./M3Node";
import { ButtonRun, CornerIcon, Field, IconBtn, Section, Segmented, SizePresets, Slider, TidyButton, TidyState, Toggle, TokenChips } from "./ui";
import { AiWriteBtn } from "./AiPanel";
import { popHistory } from "../lib/ai";
import { KIND_TEXT, SWIPE_TEXT, TRANSITION_TEXT, t, useLang } from "../lib/i18n";
import { useHost } from "../lib/host";
/** A text field for a web address: what is typed stays in the box, and only a complete
* http(s) address (or an emptied box) reaches the part. */
function UrlField({ value, onChange, placeholder, p }: { value: string; onChange: (src: string | undefined) => void; placeholder: string; p: Palette }) {
const [text, setText] = useState(value);
useEffect(() => setText(value), [value]);
return (
<div onBlurCapture={() => setText(value)}>
<Field
value={text}
onChange={(v) => {
setText(v);
const s = v.trim();
/* an emptied box removes a URL; a picked file (which shows as an empty box) is left alone */
if (!s && value) onChange(undefined);
else if (/^https?:\/\/\S+$/.test(s)) onChange(s);
}}
placeholder={placeholder}
p={p}
icon="link"
/>
</div>
);
}
export function variantsOf(kind: Kind): { key: Variant; label: string }[] {
const variants = VARIANTS.map((v) => ({ ...v, label: t(v.key) }));
switch (kind) {
case "card":
return [
{ key: "tonal", label: t("filled") },
{ key: "elevated", label: t("elevated") },
{ key: "outlined", label: t("outlined") },
];
case "textField":
return [
{ key: "outlined", label: t("outlined") },
{ key: "filled", label: t("filled") },
];
case "chip":
return [
{ key: "outlined", label: t("outlined") },
{ key: "tonal", label: t("elevated") },
];
case "fab":
case "extendedFab":
case "fabMenu":
return variants.filter((v) => v.key !== "text" && v.key !== "elevated" && v.key !== "outlined");
case "splitButton":
return variants.filter((v) => v.key !== "text");
case "toolbar":
return [
{ key: "tonal", label: t("standard") },
{ key: "filled", label: t("vibrant") },
];
case "iconButton":
return variants.filter((v) => v.key !== "elevated" && v.key !== "text").concat({
key: "text",
label: t("standard"),
});
default:
return variants;
}
}
export function VariantSwatch({
v,
label,
p,
on,
onClick,
small,
}: {
v: Variant;
label: string;
p: Palette;
on: boolean;
onClick: () => void;
small?: boolean;
}) {
const st = variantStyle(v, p);
const h = small ? 32 : 40;
return (
<button
onClick={onClick}
title={label}
aria-label={label}
aria-pressed={on}
className="m3-press"
style={{
height: h,
borderRadius: h / 2,
cursor: "pointer",
fontSize: small ? 11 : 12,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
padding: small ? "0 10px" : "0 12px",
...st,
boxShadow: v === "elevated" ? "0 1px 3px rgba(0,0,0,0.2)" : "none",
outline: on ? `2px solid ${p.primary}` : "2px solid transparent",
outlineOffset: 2,
}}
>
{on && <Icon name="check" size={small ? 14 : 16} />}
{label}
</button>
);
}
const MAX_IMAGE_PX = 1200;
/** hover text for a width preset derived from the selected frame */
export const widthPresetLabel = (v: number, frameWidth = PHONE_W): string | undefined =>
v === frameWidth
? t("screenWidth")
: v === contentWidth(frameWidth)
? t("contentWidth")
: v === halfWidth(frameWidth)
? t("halfWidth")
: frameWidth !== PHONE_W && v === CONTENT_W
? t("columnWidth")
: undefined;
const heightPresetLabel = (v: number, frameHeight = PHONE_H): string | undefined =>
v === frameHeight ? t("screenHeight") : v === frameHeight / 2 ? t("halfHeight") : undefined;
export function FrameSizePicker({
frame,
palette: p,
onChange,
compact,
}: {
frame: Frame;
palette: Palette;
onChange: (preset: FramePreset) => void;
compact?: boolean;
}) {
const lang = useLang();
return (
<Segmented<FramePreset>
options={[
{ key: "phone", icon: "smartphone", label: compact ? undefined : t("phoneFrame", lang), title: t("phoneFrame", lang) },
{ key: "desktop", icon: "desktop_windows", label: compact ? undefined : t("desktopFrame", lang), title: t("desktopFrame", lang) },
]}
value={framePresetOf(frame)}
onChange={onChange}
p={p}
height={compact ? 36 : 40}
grow={!compact}
/>
);
}
/** Downscale a picked file so the document stays small enough for localStorage. */
function readImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const s = Math.min(1, MAX_IMAGE_PX / Math.max(img.width, img.height));
const c = document.createElement("canvas");
c.width = Math.max(1, Math.round(img.width * s));
c.height = Math.max(1, Math.round(img.height * s));
c.getContext("2d")!.drawImage(img, 0, 0, c.width, c.height);
URL.revokeObjectURL(url);
resolve(c.toDataURL("image/webp", 0.86));
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("image"));
};
img.src = url;
});
}
function FrameChips({
frames,
value,
onChange,
p,
back,
small,
}: {
frames: Frame[];
value: string | null;
onChange: (id: string | null) => void;
p: Palette;
/** offer "go back" as a target */
back?: boolean;
small?: boolean;
}) {
const lang = useLang();
const h = small ? 32 : 36;
const chip = (id: string | null, label: string, icon: string) => {
const on = value === id;
return (
<button
key={id ?? "none"}
onClick={() => onChange(id)}
className="m3-press"
style={{
height: h,
padding: "0 12px 0 8px",
borderRadius: h / 2,
border: "none",
background: on ? p.primary : p.surfaceContainerHigh,
color: on ? p.onPrimary : p.onSurfaceVariant,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
maxWidth: "100%",
}}
>
<Icon name={icon} size={18} />
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
</button>
);
};
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{chip(null, t("none", lang), "block")}
{back && chip(BACK_TARGET, t("goBack", lang), "arrow_back")}
{frames.map((f) => chip(f.id, f.name || t("screen", lang), isPhoneFrame(f) ? "smartphone" : "desktop_windows"))}
</div>
);
}
function TransitionPicker({ value, onChange, p }: { value: Transition; onChange: (t: Transition) => void; p: Palette }) {
const lang = useLang();
return (
<Segmented<Transition>
options={TRANSITIONS.map((tr) => ({ key: tr.key, icon: tr.icon, title: TRANSITION_TEXT[lang][tr.key] }))}
value={value}
onChange={onChange}
p={p}
height={34}
/>
);
}
/** target frame (or back) plus the transition, for one tap target */
function ActionEditor({
frames,
action,
onChange,
p,
}: {
frames: Frame[];
action: Action | undefined;
onChange: (a: Action | undefined) => void;
p: Palette;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<FrameChips
frames={frames}
value={action?.to ?? null}
onChange={(to) => onChange(to ? { to, transition: action?.transition ?? "slide" } : undefined)}
p={p}
back
/>
{action && action.to !== BACK_TARGET && (
<TransitionPicker value={action.transition} onChange={(transition) => onChange({ ...action, transition })} p={p} />
)}
</div>
);
}
/** what a field's AI button needs from the page; `reason` explains a disabled button */
export type AiHooks = { ready: boolean; reason?: string; busy: boolean; onRun: () => void; onCancel: () => void };
/** a multiline field with the AI button under it, fused with a button that swaps the AI text and the original once the AI has written it */
function AiField({ ai, history, onRestore, p, value, onChange, placeholder }: { ai: AiHooks; history?: string[]; onRestore: () => void; p: Palette; value: string; onChange: (v: string) => void; placeholder: string }) {
const lang = useLang();
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Field value={value} onChange={onChange} placeholder={placeholder} p={p} multiline rows={3} />
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<ButtonRun>
<AiWriteBtn p={p} busy={ai.busy} disabled={!ai.ready} onClick={ai.onRun} onCancel={ai.onCancel} label={t("aiWriteShort", lang)} title={ai.ready ? t("aiWrite", lang) : (ai.reason ?? t("aiNoKey", lang))} />
{!!history?.length && <IconBtn icon="undo" p={p} size={40} on onClick={onRestore} title={t("aiRestore", lang)} />}
</ButtonRun>
</div>
</div>
);
}
export function FrameInspector({
frame,
palette: p,
onChange,
onDelete,
onDuplicate,
onPreview,
prompt,
onSaveImage,
frames,
tidy,
onTidy,
ai,
onSize,
}: {
frame: Frame;
palette: Palette;
onChange: (patch: Partial<Frame>) => void;
onDelete: () => void;
onDuplicate: () => void;
onPreview: () => void;
prompt: string;
onSaveImage: () => Promise<void>;
frames: Frame[];
/** what the tidy button offers: tidy the screen, undo the last tidy, or nothing (already tidy) */
tidy: TidyState;
onTidy: () => void;
ai: AiHooks;
onSize: (preset: FramePreset) => void;
}) {
const lang = useLang();
const host = useHost();
const [copied, setCopied] = useState(false);
const [saving, setSaving] = useState(false);
const [swipeDir, setSwipeDir] = useState<SwipeDir>("left");
useEffect(() => {
if (!copied) return;
const t = setTimeout(() => setCopied(false), 1400);
return () => clearTimeout(t);
}, [copied]);
const actionBtn = (icon: string, label: string, onClick: () => void, busy?: boolean) => (
<button
onClick={onClick}
disabled={busy}
className="m3-press"
style={{
flex: 1,
height: 44,
borderRadius: 22,
border: "none",
background: p.secondaryContainer,
color: p.onSecondaryContainer,
fontSize: 13,
fontWeight: 600,
cursor: busy ? "default" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
opacity: busy ? 0.6 : 1,
}}
>
<Icon name={icon} size={20} />
{label}
</button>
);
return (
<div className="no-scrollbar" style={{ padding: "12px 12px 20px", overflowY: "auto", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
padding: "6px 6px 6px 14px",
borderRadius: 20,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
}}
>
<Icon name={isPhoneFrame(frame) ? "smartphone" : "desktop_windows"} size={20} />
<span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0 }}>{t("screen", lang)}</span>
<IconBtn icon="play_arrow" p={p} onClick={onPreview} title={t("previewFrom", lang)} size={32} fill />
<IconBtn icon="content_copy" p={p} onClick={onDuplicate} title={t("duplicate", lang)} size={32} />
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("delete", lang)} size={32} />
</div>
<Section id="frame-size" icon="aspect_ratio" title={t("frameSize", lang)} p={p}>
<FrameSizePicker frame={frame} palette={p} onChange={onSize} />
</Section>
<Section id="frame-name" icon="label" title={t("name", lang)} p={p}>
<Field value={frame.name} onChange={(name) => onChange({ name })} placeholder={t("screenName", lang)} p={p} icon={isPhoneFrame(frame) ? "smartphone" : "desktop_windows"} />
</Section>
<Section id="frame-note" icon="notes" title={t("description", lang)} p={p}>
<AiField ai={ai} history={frame.noteHistory} onRestore={() => onChange(popHistory(frame.note, frame.noteHistory, "note", "noteHistory"))} p={p} value={frame.note ?? ""} onChange={(note) => onChange({ note: note || undefined })} placeholder={t("screenDescription", lang)} />
</Section>
<Section id="frame-bg" icon="format_color_fill" title={t("background", lang)} p={p}>
<TokenChips value={frame.bg ?? "surface"} onChange={(bg) => onChange({ bg })} p={p} />
</Section>
<Section id="frame-tidy" icon="align_space_even" title={t("tidy", lang)} p={p}>
<TidyButton state={tidy} onClick={onTidy} p={p} />
</Section>
{frames.length > 1 && (
<Section id="frame-swipe" icon="swipe" title={t("swipeTo", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Segmented<SwipeDir>
options={SWIPE_DIRS.map((d) => ({ key: d.key, icon: d.icon, title: SWIPE_TEXT[lang][d.key], dot: !!frame.swipe?.[d.key] }))}
value={swipeDir}
onChange={setSwipeDir}
p={p}
height={36}
/>
<FrameChips
frames={frames.filter((f) => f.id !== frame.id)}
value={frame.swipe?.[swipeDir] ?? null}
onChange={(to) => {
const swipe = { ...(frame.swipe ?? {}) };
if (to) swipe[swipeDir] = to;
else delete swipe[swipeDir];
onChange({ swipe: Object.keys(swipe).length ? swipe : undefined });
}}
p={p}
small
/>
</div>
</Section>
)}
<Section id="frame-export" icon="ios_share" title={t("export", lang)} p={p}>
<ButtonRun>
{actionBtn(
copied ? "check" : "content_copy",
copied ? t("copied", lang) : t("prompt", lang),
async () => {
try {
let handled = false;
try { handled = host.onExport?.(prompt, null) === true; } catch {}
if (handled) { setCopied(true); return; }
await navigator.clipboard.writeText(prompt);
setCopied(true);
} catch {}
},
)}
{actionBtn(
"image",
saving ? t("saving", lang) : t("saveImage", lang),
async () => {
setSaving(true);
try {
await onSaveImage();
} finally {
setSaving(false);
}
},
saving,
)}
</ButtonRun>
<div
className="no-scrollbar"
style={{
marginTop: 10,
maxHeight: 260,
overflowY: "auto",
borderRadius: 16,
background: p.surfaceContainerLow,
padding: 12,
fontSize: 12,
lineHeight: 1.7,
color: p.onSurfaceVariant,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{prompt}
</div>
</Section>
</div>
);
}
export function Inspector({
ai,
item,
palette: p,
frames,
frame,
onChange,
onDelete,
onDuplicate,
multi,
grouped,
onGroup,
onUngroup,
}: {
/** the AI button beside the behavior field */
ai: AiHooks;
item: Item | null;
palette: Palette;
frames: Frame[];
/** frame containing the selected part; its dimensions bound size controls */
frame?: Frame | null;
onChange: (patch: Partial<Item>) => void;
onDelete: () => void;
onDuplicate: () => void;
multi: number;
/** the selection is exactly one hand-made group */
grouped?: boolean;
onGroup?: () => void;
onUngroup?: () => void;
}) {
const lang = useLang();
const fileRef = useRef<HTMLInputElement>(null);
const slots = item ? iconSlotsOf(item) : [];
const [slotKey, setSlotKey] = useState("icon");
const [pickerOpen, setPickerOpen] = useState(false);
const [actionSlot, setActionSlot] = useState("");
/** editing the "on" look of a toggle button instead of its normal look */
const [onTab, setOnTab] = useState(false);
useEffect(() => {
setSlotKey(item ? (iconSlotsOf(item)[0]?.key ?? "icon") : "icon");
setPickerOpen(false);
setActionSlot("");
setOnTab(false);
}, [item?.id, item?.kind, item?.tabs?.length]);
if (!item) {
if (multi > 1) {
const bigBtn = (icon: string, label: string, onClick?: () => void) => (
<button
onClick={onClick}
className="m3-press"
style={{
height: 48,
borderRadius: 24,
border: "none",
background: p.primary,
color: p.onPrimary,
fontSize: 14,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
width: "100%",
}}
>
<Icon name={icon} size={22} />
{label}
</button>
);
return (
<div className="no-scrollbar" style={{ padding: "12px 12px 20px", overflowY: "auto", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 12,
padding: "6px 6px 6px 14px",
borderRadius: 20,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
}}
>
<Icon name={grouped ? "group_work" : "select_all"} size={20} />
<span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0 }}>
{grouped ? t("group", lang) : lang === "en" ? `${multi} ${t("selectedParts", lang)}` : `${multi}${t("selectedParts", lang)}`}
</span>
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("deleteSelection", lang)} size={32} />
</div>
{grouped ? bigBtn("ungroup", t("ungroup", lang), onUngroup) : bigBtn("group_work", t("makeGroup", lang), onGroup)}
<div style={{ marginTop: 10, fontSize: 12, lineHeight: 1.5, color: p.onSurfaceVariant, padding: "0 6px" }}>
{grouped ? t("groupEditNote", lang) : `${t("groupHint", lang)} (Ctrl+G)`}
</div>
</div>
);
}
return (
<div
style={{
height: "100%",
display: "grid",
placeItems: "center",
color: p.outlineVariant,
padding: 24,
textAlign: "center",
}}
>
<Icon name="ads_click" size={44} />
</div>
);
}
const spec = KIND_SPEC[item.kind];
const frameSize = frame ? frameSizeOf(frame) : { w: PHONE_W, h: PHONE_H };
const mapWidthPreset = (v: number) =>
v === PHONE_W ? frameSize.w : v === CONTENT_W ? contentWidth(frameSize.w) : v === HALF_W ? halfWidth(frameSize.w) : v;
const mapHeightPreset = (v: number) => (v === PHONE_H ? frameSize.h : v === PHONE_H / 2 ? frameSize.h / 2 : v);
const widthMax = (max: number) =>
max === PHONE_W ? frameSize.w : max === CONTENT_W ? contentWidth(frameSize.w) : max;
const heightMax = (max: number) => (max === PHONE_H ? frameSize.h : max);
const editOn = !!item.toggle && onTab;
/* the on-state is edited through the same text / icon / style controls:
* `shown` is what they display, `change` routes their patches into `toggle` */
const shown: Item = editOn
? {
...item,
label: item.toggle?.label ?? item.label,
icon: toggleIcon(item),
variant: item.toggle?.variant ?? item.variant,
}
: item;
const change = (patch: Partial<Item>) => {
if (!editOn) {
onChange(patch);
return;
}
const next = { ...(item.toggle ?? {}) };
if ("label" in patch) next.label = patch.label;
if ("icon" in patch) next.icon = patch.icon;
if ("variant" in patch) next.variant = patch.variant;
onChange({ toggle: next });
};
const activeSlot: { key: string; value: string | null } | undefined = (() => {
const s = slots.find((x) => x.key === slotKey) ?? slots[0];
return s && editOn && s.key === "icon" ? { ...s, value: shown.icon } : s;
})();
const actionSlots = actionSlotsOf(item);
const slotBtn = (key: string, label: string | undefined, icon: string | null, on: boolean, onClick: () => void, dim?: boolean) => (
<button
key={key}
onClick={onClick}
title={label}
className="m3-press"
style={{
height: 44,
minWidth: 44,
padding: label ? "0 14px 0 10px" : 0,
borderRadius: 22,
border: "none",
background: on ? p.primary : p.surfaceContainerHigh,
color: on ? p.onPrimary : dim ? p.outline : p.onSurface,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
fontSize: 12,
fontWeight: 600,
}}
>
<Icon name={icon ?? "block"} size={22} />
{label && <span>{label}</span>}
</button>
);
const tabs: NavTab[] = item.tabs ?? [];
const variants = spec.hasVariant ? variantsOf(item.kind) : [];
const setTabCount = (n: number) => {
const next: NavTab[] = [];
const defaults = defaultTabsFor(item.kind);
for (let i = 0; i < n; i++) next.push(tabs[i] ? { ...tabs[i] } : { ...defaults[i % defaults.length] });
onChange({ tabs: next });
};
/** entries of a tab row have no icon; toolbar buttons have no label */
const tabIcons = item.kind !== "tabs";
const tabLabels = item.kind !== "toolbar";
const mainSlots = slots.filter((s) => !s.key.startsWith("tab:"));
const setTabLabel = (i: number, label: string) =>
onChange({ tabs: tabs.map((t, j) => (j === i ? { ...t, label } : t)) });
/** bars, rails and tab rows show one destination as selected */
const hasSelected = item.kind === "bottomNav" || item.kind === "navRail" || item.kind === "tabs";
const selectedTab = Math.min(item.selected ?? 0, Math.max(0, tabs.length - 1));
const hasRadius =
item.kind === "bottomNav" ||
item.kind === "navRail" ||
item.kind === "topAppBar" ||
item.kind === "card" ||
item.kind === "image" ||
item.kind === "box";
return (
<div className="no-scrollbar" style={{ padding: "12px 12px 20px", overflowY: "auto", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginBottom: 10,
padding: "6px 6px 6px 14px",
borderRadius: 20,
background: p.secondaryContainer,
color: p.onSecondaryContainer,
}}
>
<Icon name={spec.paletteIcon} size={20} />
<span style={{ fontSize: 14, fontWeight: 600, flex: 1, minWidth: 0 }}>{KIND_TEXT[lang][item.kind]?.noun ?? spec.label}</span>
<IconBtn icon="content_copy" p={p} onClick={onDuplicate} title={t("duplicateKey", lang)} size={32} />
<IconBtn icon="delete" p={p} danger onClick={onDelete} title={t("delete", lang)} size={32} />
</div>
{TOGGLEABLE.includes(item.kind) && (
<div style={{ display: "flex", flexDirection: "column", gap: 10, padding: "10px 4px 12px", marginBottom: 12 }}>
<Toggle
on={!!item.toggle}
onChange={(on) => {
onChange({ toggle: on ? {} : undefined });
setOnTab(on);
setPickerOpen(false);
}}
p={p}
icon="swap_horiz"
label={t("toggle", lang)}
grow
/>
{item.toggle && (
<>
<Segmented<"off" | "on">
options={[
{ key: "off", icon: "radio_button_unchecked", label: t("normalState", lang) },
{ key: "on", icon: "check_circle", label: t("onState", lang) },
]}
value={onTab ? "on" : "off"}
onChange={(k) => {
setOnTab(k === "on");
setPickerOpen(false);
}}
p={p}
height={36}
/>
{editOn && <div style={{ fontSize: 11, color: p.onSurfaceVariant, padding: "0 4px" }}>{t("onStateHint", lang)}</div>}
</>
)}
</div>
)}
{(spec.hasLabel || spec.hasSupporting) && (
<Section id="text" icon="title" title={t("text", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{spec.hasLabel && (
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<Field
value={shown.label}
onChange={(label) => change({ label })}
placeholder={t("label", lang)}
p={p}
icon="short_text"
/>
{item.kind === "text" && (
<IconBtn
icon="format_bold"
p={p}
size={44}
on={!!item.bold}
onClick={() => onChange({ bold: !item.bold })}
title={t("bold", lang)}
/>
)}
</div>
)}
{spec.hasSupporting && !editOn && (
<Field
value={item.supporting ?? ""}
onChange={(supporting) => onChange({ supporting })}
placeholder={item.kind === "snackbar" ? t("action", lang) : t("supporting", lang)}
p={p}
icon="notes"
/>
)}
</div>
</Section>
)}
{spec.hasTabs && !editOn && (
<Section id="tabs" icon="view_column" title={t("tabs", lang)} p={p}>
<Segmented
options={(item.kind === "toolbar" ? [2, 3, 4, 5, 6] : [2, 3, 4, 5]).map((n) => ({ key: String(n), label: String(n) }))}
value={String(tabs.length)}
onChange={(k) => setTabCount(Number(k))}
p={p}
height={36}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
{tabs.map((tab, i) => {
const on = slotKey === `tab:${i}` && pickerOpen;
return (
<div key={i} style={{ display: "flex", gap: 6, alignItems: "center" }}>
{hasSelected && (
<IconBtn
icon={selectedTab === i ? "radio_button_checked" : "radio_button_unchecked"}
p={p}
size={40}
on={selectedTab === i}
onClick={() => onChange({ selected: i })}
title={t("selectedTab", lang)}
/>
)}
{tabIcons && (
<button
onClick={() => {
setSlotKey(`tab:${i}`);
setPickerOpen(true);
}}
title={t("changeIcon", lang)}
aria-label={t("changeIcon", lang)}
className="m3-press"
style={{
width: 40,
height: 40,
flex: "0 0 auto",
borderRadius: 20,
border: "none",
background: on ? p.primary : p.surfaceContainerHigh,
color: on ? p.onPrimary : p.onSurfaceVariant,
cursor: "pointer",
display: "grid",
placeItems: "center",
}}
>
<Icon name={tab.icon || "add"} size={20} />
</button>
)}
{tabLabels && <Field value={tab.label} onChange={(v) => setTabLabel(i, v)} placeholder={t("label", lang)} p={p} height={40} />}
{tabIcons && tab.icon && (
<IconBtn icon="close" p={p} size={40} onClick={() => onChange(setIconSlot(item, `tab:${i}`, null))} title={t("noIcon", lang)} />
)}
</div>
);
})}
</div>
</Section>
)}
{(item.kind === "image" || item.kind === "card") && !editOn && (
<Section id="image" icon="image" title={t("image", lang)} p={p}>
{item.kind === "card" && (
<div style={{ marginBottom: 10 }}>
<Toggle on={!item.noImage} onChange={(on) => onChange({ noImage: on ? undefined : true })} p={p} icon="image" label={t("imageArea", lang)} grow />
</div>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
hidden
onChange={async (e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (!f) return;
try {
onChange({ src: await readImage(f) });
} catch {}
}}
/>
<div style={{ display: "flex", gap: 6, alignItems: "center" }}>
<button
onClick={() => fileRef.current?.click()}
className="m3-press"
style={{
flex: 1,
height: 44,
borderRadius: 22,
border: "none",
background: p.primary,
color: p.onPrimary,
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<Icon name="upload" size={20} />
{t("pickImage", lang)}
</button>
{item.src && (
<IconBtn icon="close" p={p} size={44} onClick={() => onChange({ src: undefined })} title={t("removeImage", lang)} />
)}
</div>
{/* a picture on the web by its address; a picked file shows as data and is not editable here */}
<div style={{ marginTop: 8 }}>
<UrlField key={item.id} value={item.src && /^https?:\/\//.test(item.src) ? item.src : ""} onChange={(src) => onChange({ src })} placeholder={t("imageUrl", lang)} p={p} />
</div>
</Section>
)}
{mainSlots.length > 0 && activeSlot && !item.src && (
<Section id="icon" icon="emoji_symbols" title={t("icon", lang)} p={p}>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{mainSlots.map((s) =>
slotBtn(
s.key,
mainSlots.length > 1 ? s.label : undefined,
editOn && s.key === "icon" ? shown.icon : s.value,
s.key === activeSlot.key && pickerOpen,
() => {
const on = s.key === activeSlot.key && pickerOpen;
setSlotKey(s.key);
setPickerOpen(!on);
},
!s.value,
),
)}
{activeSlot.value && !activeSlot.key.startsWith("tab:") && (
<IconBtn
icon="close"
p={p}
size={44}
onClick={() => {
// a slot without an icon cannot be tapped, so its action goes too
const patch: Partial<Item> = setIconSlot(item, activeSlot.key, null);
if (!editOn && item.actions?.[activeSlot.key]) {
const actions = { ...item.actions };
delete actions[activeSlot.key];
patch.actions = Object.keys(actions).length ? actions : undefined;
}
change(patch);
}}
title={t("noIcon", lang)}
/>
)}
</div>
</Section>
)}
{pickerOpen && activeSlot && (
<div style={{ margin: "-4px 4px 12px" }}>
<IconPicker
value={activeSlot.value}
onChange={(icon) => change(setIconSlot(item, activeSlot.key, icon))}
palette={p}
/>
</div>
)}
{variants.length > 0 && (
<Section id="style" icon="palette" title={t("style", lang)} p={p}>
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{variants.map((v) => (
<VariantSwatch
key={v.key}
v={v.key}
label={v.label}
p={p}
on={shown.variant === v.key}
onClick={() => change({ variant: v.key })}
/>
))}
</div>
</Section>
)}
{spec.hasFill && !editOn && (
<Section id="fill" icon="format_color_fill" title={t("background", lang)} p={p}>
<TokenChips
value={item.kind === "card" ? cardFillOf(item) : (item.fill ?? "surfaceContainerLow")}
onChange={(fill) => onChange({ fill })}
p={p}
none={item.kind === "card"}
noneOn={item.kind === "card" && !item.fill}
onNone={() => onChange({ fill: undefined })}
/>
{item.kind === "listItem" && (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: p.onSurfaceVariant, margin: "10px 0 6px" }}>{t("iconBackground", lang)}</div>
<TokenChips
value={item.iconFill && item.iconFill !== "none" ? item.iconFill : "primaryContainer"}
onChange={(iconFill) => onChange({ iconFill })}
p={p}
none
noneOn={item.iconFill === "none"}
onNone={() => onChange({ iconFill: "none" })}
/>
</>
)}
</Section>
)}
{(spec.hasChecked || spec.hasValue || spec.hasWavy || spec.hasContained || item.kind === "listItem") && !editOn && (
<Section id="state" icon="tune" title={t("state", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 14, padding: "2px 0" }}>
{item.kind === "listItem" && (
/* a switch at the trailing end takes the place of the trailing icon */
<Toggle on={!!item.switch} onChange={(on) => onChange({ switch: on || undefined })} p={p} icon="toggle_on" label={t("listSwitch", lang)} grow />
)}
{item.kind === "listItem" && item.switch && (
<Toggle on={!!item.checked} onChange={(checked) => onChange({ checked })} p={p} icon="toggle_on" label={t("on", lang)} grow />
)}
{spec.hasChecked && (
<Toggle
on={!!item.checked}
onChange={(checked) => onChange({ checked })}
p={p}
icon={item.kind === "chip" ? "check_circle" : item.kind === "box" ? "drag_handle" : "toggle_on"}
label={item.kind === "chip" ? t("selected", lang) : item.kind === "box" ? t("handle", lang) : t("on", lang)}
grow
/>
)}
{item.kind === "switch" && (
<Toggle on={!item.noCheck} onChange={(on) => onChange({ noCheck: on ? undefined : true })} p={p} icon="check" label={t("thumbCheck", lang)} grow />
)}
{spec.hasContained && (
<Toggle
on={!!item.contained}
onChange={(contained) => onChange({ contained })}
p={p}
icon="circle"
label={t("container", lang)}
grow
/>
)}
{spec.hasWavy && (
<Toggle on={!!item.wavy} onChange={(wavy) => onChange({ wavy })} p={p} icon="airwave" label={t("wavy", lang)} grow />
)}
{spec.hasValue && item.kind !== "slider" && (
<Toggle
on={item.value !== undefined}
onChange={(on) => onChange({ value: on ? 60 : undefined })}
p={p}
icon="percent"
label={t("determinate", lang)}
grow
/>
)}
{spec.hasValue && (item.kind === "slider" || item.value !== undefined) && (
<Slider
icon="percent"
value={item.value ?? 40}
min={0}
max={100}
step={1}
onChange={(value) => onChange({ value })}
p={p}
unit="%"
/>
)}
</div>
</Section>
)}
{(spec.size || hasRadius) && !editOn && (
<Section id="size" icon="straighten" title={t("size", lang)} p={p}>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{spec.size && (
<>
<Slider
icon={spec.size.icon}
title={
item.kind === "text"
? t("fontSize", lang)
: spec.size.icon === "width"
? t("width", lang)
: t("size", lang)
}
value={item.size ?? spec.defSize ?? spec.w}
min={spec.size.min}
max={widthMax(spec.size.max)}
step={spec.size.step}
onChange={(size) => onChange({ size })}
p={p}
unit={item.kind === "text" ? "sp" : ""}
/>
{spec.size.presets && (
<div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
{(item.kind === "button" || item.kind === "switch") && (
/* these two are as wide as their text unless a width was set; this chip goes back to that */
<button
onClick={() => onChange({ size: undefined })}
aria-pressed={item.size === undefined}
className="m3-press"
style={{
height: 28,
padding: "0 12px",
borderRadius: 14,
border: "none",
background: item.size === undefined ? p.secondaryContainer : p.surfaceContainerHigh,
color: item.size === undefined ? p.onSecondaryContainer : p.onSurfaceVariant,
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
}}
>
{t("autoWidth", lang)}
</button>
)}
<SizePresets
values={[...new Set([...(frameSize.w !== PHONE_W && spec.size.icon === "width" && spec.size.presets.includes(CONTENT_W) ? [CONTENT_W] : []), ...spec.size.presets.map(mapWidthPreset)])].sort((a, b) => a - b)}
value={item.size ?? spec.defSize ?? spec.w}
min={spec.size.min}
max={widthMax(spec.size.max)}
onChange={(size) => onChange({ size })}
p={p}
labelOf={item.kind === "text" ? undefined : (v) => widthPresetLabel(v, frameSize.w)}
/>
</div>
)}
</>
)}
{spec.size2 && (
<>
<Slider
icon={spec.size2.icon}
title={t("height", lang)}
value={item.size2 ?? spec.h}
min={spec.size2.min}
max={heightMax(spec.size2.max)}
step={spec.size2.step}
onChange={(size2) => onChange({ size2 })}
p={p}
/>
{spec.size2.presets && (
<SizePresets
values={[...new Set(spec.size2.presets.map(mapHeightPreset))]}
value={item.size2 ?? spec.h}
min={spec.size2.min}
max={heightMax(spec.size2.max)}
onChange={(size2) => onChange({ size2 })}
p={p}
labelOf={(v) => heightPresetLabel(v, frameSize.h)}
/>
)}
</>
)}
{hasRadius && (item.kind === "card" || item.kind === "image") && (
<Slider
icon="rounded_corner"
title={t("cornerRadius", lang)}
value={item.radiusTop ?? spec.radius}
min={0}
max={48}
step={1}
onChange={(radiusTop) => onChange({ radiusTop })}
p={p}
/>
)}
{hasRadius && item.kind === "box" && (
<Toggle
on={!!item.corners}
onChange={(each) =>
onChange(
each
? { corners: { tl: item.radiusTop ?? 0, tr: item.radiusTop ?? 0, bl: item.radiusBottom ?? 0, br: item.radiusBottom ?? 0 } }
: { corners: undefined, radiusTop: item.corners?.tl ?? item.radiusTop, radiusBottom: item.corners?.bl ?? item.radiusBottom },
)
}
p={p}
icon="crop_free"
label={t("cornersEach", lang)}
grow
/>
)}
{hasRadius && item.kind === "box" && item.corners && (
<>
{(["tl", "tr", "bl", "br"] as const).map((k) => (
<Slider
key={k}
iconNode={<CornerIcon side={k} />}
title={t(k === "tl" ? "cornerTl" : k === "tr" ? "cornerTr" : k === "bl" ? "cornerBl" : "cornerBr", lang)}
value={item.corners![k]}
min={0}
max={40}
step={1}
onChange={(v) => onChange({ corners: { ...item.corners!, [k]: v } })}
p={p}
/>
))}
</>
)}
{hasRadius && (item.kind === "bottomNav" || item.kind === "navRail" || item.kind === "topAppBar" || (item.kind === "box" && !item.corners)) && (
<>
{/* a rail's two sliders are its left and right sides; the fields are shared with the bars */}
<Slider
iconNode={<CornerIcon side={item.kind === "navRail" ? "left" : "top"} />}
title={t(item.kind === "navRail" ? "cornerLeft" : "cornerTop", lang)}
value={item.radiusTop ?? 0}
min={0}
max={40}
step={1}
onChange={(radiusTop) => onChange({ radiusTop })}
p={p}
/>
<Slider
iconNode={<CornerIcon side={item.kind === "navRail" ? "right" : "bottom"} />}
title={t(item.kind === "navRail" ? "cornerRight" : "cornerBottom", lang)}
value={item.radiusBottom ?? 0}
min={0}
max={40}
step={1}
onChange={(radiusBottom) => onChange({ radiusBottom })}
p={p}
/>
</>
)}
</div>
</Section>
)}
{(TAPPABLE.includes(item.kind) || actionSlots.length > 0) && frames.length > 0 && !editOn && (
<Section id="action" icon="ads_click" title={t("tapTo", lang)} p={p}>
{actionSlots.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Segmented<string>
options={actionSlots.map((s) => ({
key: s.key,
icon: s.value ?? undefined,
label: s.value ? undefined : s.label,
title: s.label,
dot: !!item.actions?.[s.key],
}))}
value={actionSlot || actionSlots[0].key}
onChange={setActionSlot}
p={p}
height={40}
/>
{(() => {
const key = actionSlot || actionSlots[0].key;
return (
<ActionEditor
frames={frames}
action={item.actions?.[key]}
onChange={(a) => {
const actions = { ...(item.actions ?? {}) };
if (a) actions[key] = a;
else delete actions[key];
onChange({ actions: Object.keys(actions).length ? actions : undefined });
}}
p={p}
/>
);
})()}
</div>
) : (
<ActionEditor frames={frames} action={item.action} onChange={(action) => onChange({ action })} p={p} />
)}
</Section>
)}
{!editOn && (
<Section id="note" icon="bolt" title={t("behavior", lang)} p={p}>
<AiField
ai={ai}
history={item.noteHistory}
onRestore={() => onChange(popHistory(item.note, item.noteHistory, "note", "noteHistory"))}
p={p}
value={item.note ?? ""}
onChange={(note) => onChange({ note })}
placeholder={item.kind === "button" || item.kind === "fab" || item.kind === "iconButton" || item.kind === "extendedFab" ? t("whenPressed", lang) : t("whatItDoes", lang)}
/>
</Section>
)}
</div>
);
}