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.
889 lines
25 KiB
TypeScript
889 lines
25 KiB
TypeScript
// @ts-nocheck — vendored, see PROVENANCE.md
|
|
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { COLOR_TOKENS, ColorToken, Palette, R_INNER, clamp } from "../lib/tokens";
|
|
import { AnimatePresence, motion } from "motion/react";
|
|
import { COLOR_TOKEN_TEXT, t, useLang } from "../lib/i18n";
|
|
import { Icon } from "./M3Node";
|
|
|
|
export function IconBtn({
|
|
icon,
|
|
on,
|
|
onClick,
|
|
title,
|
|
size = 36,
|
|
p,
|
|
danger,
|
|
disabled,
|
|
fill,
|
|
}: {
|
|
icon: string;
|
|
on?: boolean;
|
|
onClick?: () => void;
|
|
title?: string;
|
|
size?: number;
|
|
p: Palette;
|
|
danger?: boolean;
|
|
disabled?: boolean;
|
|
fill?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={title}
|
|
aria-label={title}
|
|
disabled={disabled}
|
|
className="m3-press"
|
|
style={{
|
|
width: size,
|
|
height: size,
|
|
borderRadius: size / 2,
|
|
border: "none",
|
|
background: on ? (danger ? p.errorContainer : p.secondaryContainer) : "transparent",
|
|
color: disabled
|
|
? p.outlineVariant
|
|
: danger
|
|
? p.error
|
|
: on
|
|
? p.onSecondaryContainer
|
|
: p.onSurfaceVariant,
|
|
cursor: disabled ? "default" : "pointer",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
flex: "0 0 auto",
|
|
}}
|
|
>
|
|
<Icon name={icon} size={Math.round(size * 0.58)} fill={fill ?? on} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export type SegOption<K extends string> = { key: K; icon?: string; label?: string; title?: string; /** small marker: this option carries something */ dot?: boolean; /** this option alone takes the spare width */ grow?: boolean; /** an icon-only option that should not shrink to a square */ wide?: boolean };
|
|
|
|
/** Connected-button group with the same fused corners as the canvas. */
|
|
export function Segmented<K extends string>({
|
|
options,
|
|
value,
|
|
onChange,
|
|
p,
|
|
height = 40,
|
|
grow = true,
|
|
}: {
|
|
options: SegOption<K>[];
|
|
value: K;
|
|
onChange: (k: K) => void;
|
|
p: Palette;
|
|
height?: number;
|
|
grow?: boolean;
|
|
}) {
|
|
return (
|
|
<div style={{ display: "flex", gap: 3 }}>
|
|
{options.map((o, i) => {
|
|
const on = o.key === value;
|
|
const first = i === 0;
|
|
const last = i === options.length - 1;
|
|
const outer = height / 2;
|
|
return (
|
|
<button
|
|
key={o.key}
|
|
onClick={() => onChange(o.key)}
|
|
title={o.title ?? o.label}
|
|
aria-label={o.title ?? o.label}
|
|
className="m3-press"
|
|
style={{
|
|
flex: (o.grow ?? grow) ? 1 : "0 0 auto",
|
|
minWidth: o.wide ? height * 1.4 : height,
|
|
height,
|
|
padding: o.label ? "0 14px" : 0,
|
|
border: "none",
|
|
cursor: "pointer",
|
|
borderTopLeftRadius: first ? outer : R_INNER,
|
|
borderBottomLeftRadius: first ? outer : R_INNER,
|
|
borderTopRightRadius: last ? outer : R_INNER,
|
|
borderBottomRightRadius: last ? outer : R_INNER,
|
|
background: on ? p.primary : p.surfaceContainerHigh,
|
|
color: on ? p.onPrimary : p.onSurfaceVariant,
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
fontSize: 13,
|
|
fontWeight: on ? 600 : 500,
|
|
transition: "background 120ms, color 120ms, border-radius 160ms",
|
|
position: "relative",
|
|
}}
|
|
>
|
|
{o.icon && <Icon name={o.icon} size={Math.round(height * 0.5)} fill={on} />}
|
|
{o.label && <span>{o.label}</span>}
|
|
{o.dot && (
|
|
<span
|
|
aria-hidden
|
|
style={{
|
|
position: "absolute",
|
|
top: 5,
|
|
right: 7,
|
|
width: 6,
|
|
height: 6,
|
|
borderRadius: 3,
|
|
background: on ? p.onPrimary : p.primary,
|
|
}}
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Field({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
p,
|
|
icon,
|
|
multiline,
|
|
rows = 3,
|
|
height = 44,
|
|
}: {
|
|
value: string;
|
|
onChange: (v: string) => void;
|
|
placeholder?: string;
|
|
p: Palette;
|
|
icon?: string;
|
|
multiline?: boolean;
|
|
rows?: number;
|
|
height?: number;
|
|
}) {
|
|
const lang = useLang();
|
|
const filled = value.length > 0;
|
|
const base: React.CSSProperties = {
|
|
width: "100%",
|
|
padding: multiline ? `12px ${filled ? 40 : 14}px 12px ${icon ? 42 : 14}px` : `0 ${filled ? 40 : 14}px 0 ${icon ? 42 : 14}px`,
|
|
borderRadius: multiline ? 18 : height / 2,
|
|
border: "none",
|
|
background: p.surfaceContainerHigh,
|
|
color: p.onSurface,
|
|
fontSize: 14,
|
|
lineHeight: multiline ? 1.55 : undefined,
|
|
outline: "none",
|
|
boxSizing: "border-box",
|
|
fontFamily: "inherit",
|
|
resize: "none",
|
|
};
|
|
return (
|
|
<div style={{ position: "relative", width: "100%" }}>
|
|
{icon && (
|
|
<span
|
|
style={{
|
|
position: "absolute",
|
|
left: 12,
|
|
top: multiline ? 12 : (height - 20) / 2,
|
|
color: p.onSurfaceVariant,
|
|
pointerEvents: "none",
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
<Icon name={icon} size={20} />
|
|
</span>
|
|
)}
|
|
{multiline ? (
|
|
<textarea
|
|
value={value}
|
|
rows={rows}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
style={base}
|
|
/>
|
|
) : (
|
|
<input
|
|
value={value}
|
|
onChange={(e) => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
style={{ ...base, height }}
|
|
/>
|
|
)}
|
|
{filled && (
|
|
<button
|
|
onClick={() => onChange("")}
|
|
title={t("clear", lang)}
|
|
aria-label={t("clear", lang)}
|
|
style={{
|
|
position: "absolute",
|
|
right: 6,
|
|
top: multiline ? 8 : (height - 30) / 2,
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 15,
|
|
border: "none",
|
|
background: "transparent",
|
|
color: p.onSurfaceVariant,
|
|
cursor: "pointer",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
}}
|
|
>
|
|
<Icon name="close" size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Slider with a typed-in number beside it. The slider moves in `step`s; the
|
|
* field accepts any whole number and is clamped to min..max when it commits. */
|
|
/** A rectangle whose top or bottom corners are rounded: the corner-radius slider icons. */
|
|
/** a pair of corners on one side, or a single corner */
|
|
export function CornerIcon({ side, size = 20 }: { side: "top" | "bottom" | "left" | "right" | "tl" | "tr" | "bl" | "br"; size?: number }) {
|
|
const PATHS: Record<typeof side, string> = {
|
|
top: "M4 17 V9 a5 5 0 0 1 5 -5 h6 a5 5 0 0 1 5 5 v8",
|
|
bottom: "M4 3 v8 a5 5 0 0 0 5 5 h6 a5 5 0 0 0 5 -5 v-8",
|
|
left: "M17 4 H9 a5 5 0 0 0 -5 5 v6 a5 5 0 0 0 5 5 h8",
|
|
right: "M7 4 h8 a5 5 0 0 1 5 5 v6 a5 5 0 0 1 -5 5 h-8",
|
|
tl: "M5 20 V11 a6 6 0 0 1 6 -6 H20",
|
|
tr: "M4 5 H13 a6 6 0 0 1 6 6 V20",
|
|
bl: "M5 4 V13 a6 6 0 0 0 6 6 H20",
|
|
br: "M20 4 V13 a6 6 0 0 1 -6 6 H4",
|
|
};
|
|
const d = PATHS[side];
|
|
return (
|
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden>
|
|
<path d={d} />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function Slider({
|
|
icon = "tune",
|
|
iconNode,
|
|
value,
|
|
min,
|
|
max,
|
|
step,
|
|
onChange,
|
|
p,
|
|
unit = "",
|
|
title,
|
|
}: {
|
|
icon?: string;
|
|
/** custom glyph shown instead of the Material Symbol */
|
|
iconNode?: React.ReactNode;
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
step: number;
|
|
onChange: (v: number) => void;
|
|
p: Palette;
|
|
unit?: string;
|
|
title?: string;
|
|
}) {
|
|
const [text, setText] = useState(String(value));
|
|
const [editing, setEditing] = useState(false);
|
|
useEffect(() => {
|
|
if (!editing) setText(String(value));
|
|
}, [value, editing]);
|
|
const commit = () => {
|
|
const n = Math.round(Number(text));
|
|
if (Number.isFinite(n) && text.trim() !== "") onChange(clamp(n, min, max));
|
|
setEditing(false);
|
|
setText(String(value));
|
|
};
|
|
return (
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
|
<span title={title} style={{ color: p.onSurfaceVariant, lineHeight: 1, flex: "0 0 auto", display: "inline-flex" }}>
|
|
{iconNode ?? <Icon name={icon} size={20} />}
|
|
</span>
|
|
<input
|
|
type="range"
|
|
className="m3-range"
|
|
aria-label={title}
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
style={{ "--track": p.secondaryContainer, "--thumb": p.primary } as React.CSSProperties}
|
|
/>
|
|
<span style={{ position: "relative", flex: "0 0 auto", display: "inline-flex", alignItems: "center" }}>
|
|
<input
|
|
type="number"
|
|
inputMode="numeric"
|
|
aria-label={title}
|
|
min={min}
|
|
max={max}
|
|
value={editing ? text : String(value)}
|
|
onFocus={(e) => {
|
|
setEditing(true);
|
|
setText(String(value));
|
|
e.currentTarget.select();
|
|
}}
|
|
onChange={(e) => {
|
|
setText(e.target.value);
|
|
// apply as you type once the number is already in range, so the canvas follows
|
|
const n = Math.round(Number(e.target.value));
|
|
if (e.target.value.trim() !== "" && Number.isFinite(n) && n >= min && n <= max) onChange(n);
|
|
}}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") e.currentTarget.blur();
|
|
if (e.key === "Escape") {
|
|
setText(String(value));
|
|
e.currentTarget.blur();
|
|
}
|
|
}}
|
|
className="m3-number"
|
|
style={{
|
|
width: unit ? 56 : 52,
|
|
height: 30,
|
|
padding: unit ? "0 18px 0 6px" : "0 6px",
|
|
borderRadius: 8,
|
|
border: "none",
|
|
background: p.surfaceContainerHigh,
|
|
color: p.onSurface,
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
textAlign: "right",
|
|
fontVariantNumeric: "tabular-nums",
|
|
fontFamily: "inherit",
|
|
outline: editing ? `2px solid ${p.primary}` : "none",
|
|
outlineOffset: -1,
|
|
boxSizing: "border-box",
|
|
}}
|
|
/>
|
|
{unit && (
|
|
<span
|
|
style={{
|
|
position: "absolute",
|
|
right: 6,
|
|
fontSize: 11,
|
|
color: p.onSurfaceVariant,
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
{unit}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Quick picks for a size: the values the frame is built from (screen width,
|
|
* content width, half a row) or a component's standard sizes. */
|
|
export function SizePresets({
|
|
values,
|
|
value,
|
|
min,
|
|
max,
|
|
onChange,
|
|
p,
|
|
labelOf,
|
|
}: {
|
|
values: number[];
|
|
value: number;
|
|
min: number;
|
|
max: number;
|
|
onChange: (v: number) => void;
|
|
p: Palette;
|
|
/** optional hover text for a value, e.g. "Screen width" for 412 */
|
|
labelOf?: (v: number) => string | undefined;
|
|
}) {
|
|
const shown = values.filter((v) => v >= min && v <= max);
|
|
if (shown.length === 0) return null;
|
|
return (
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, paddingLeft: 30 }}>
|
|
{shown.map((v) => {
|
|
const on = v === value;
|
|
const label = labelOf?.(v);
|
|
return (
|
|
<button
|
|
key={v}
|
|
onClick={() => onChange(v)}
|
|
title={label}
|
|
aria-label={label ? `${label}: ${v}` : String(v)}
|
|
aria-pressed={on}
|
|
className="m3-press"
|
|
style={{
|
|
height: 28,
|
|
padding: "0 10px",
|
|
borderRadius: 14,
|
|
border: on ? "1px solid transparent" : `1px solid ${p.outlineVariant}`,
|
|
background: on ? p.secondaryContainer : "transparent",
|
|
color: on ? p.onSecondaryContainer : p.onSurfaceVariant,
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
fontVariantNumeric: "tabular-nums",
|
|
cursor: "pointer",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{v}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Toggle({
|
|
on,
|
|
onChange,
|
|
p,
|
|
icon,
|
|
label,
|
|
grow,
|
|
}: {
|
|
on: boolean;
|
|
onChange: (v: boolean) => void;
|
|
p: Palette;
|
|
icon?: string;
|
|
label?: string;
|
|
/** fill the row and push the switch to the trailing edge */
|
|
grow?: boolean;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={() => onChange(!on)}
|
|
title={label}
|
|
aria-label={label}
|
|
aria-pressed={on}
|
|
style={{
|
|
display: grow ? "flex" : "inline-flex",
|
|
width: grow ? "100%" : undefined,
|
|
alignItems: "center",
|
|
gap: 10,
|
|
border: "none",
|
|
background: "transparent",
|
|
cursor: "pointer",
|
|
padding: 0,
|
|
color: p.onSurfaceVariant,
|
|
}}
|
|
>
|
|
{icon && <Icon name={icon} size={20} />}
|
|
{label && <span style={{ fontSize: 13, color: p.onSurface, flex: grow ? 1 : undefined, textAlign: "left" }}>{label}</span>}
|
|
<span
|
|
style={{
|
|
position: "relative",
|
|
width: 44,
|
|
height: 26,
|
|
borderRadius: 13,
|
|
background: on ? p.primary : p.surfaceContainerHighest,
|
|
border: on ? "2px solid transparent" : `2px solid ${p.outline}`,
|
|
boxSizing: "border-box",
|
|
transition: "background 160ms",
|
|
flex: "0 0 auto",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
position: "absolute",
|
|
top: "50%",
|
|
left: on ? 20 : 3,
|
|
width: on ? 18 : 12,
|
|
height: on ? 18 : 12,
|
|
marginTop: on ? -9 : -6,
|
|
borderRadius: 9,
|
|
background: on ? p.onPrimary : p.outline,
|
|
transition: "left 160ms, width 160ms, height 160ms, margin 160ms",
|
|
}}
|
|
/>
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** Collapsible section; collapsed state is remembered per key. */
|
|
export function Section({
|
|
id,
|
|
icon,
|
|
title,
|
|
p,
|
|
children,
|
|
right,
|
|
defaultOpen = true,
|
|
}: {
|
|
id: string;
|
|
icon: string;
|
|
title: string;
|
|
p: Palette;
|
|
children: React.ReactNode;
|
|
right?: React.ReactNode;
|
|
defaultOpen?: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(defaultOpen);
|
|
useEffect(() => {
|
|
try {
|
|
const v = localStorage.getItem(`m3e:sec:${id}`);
|
|
if (v !== null) setOpen(v === "1");
|
|
} catch {}
|
|
}, [id]);
|
|
const toggle = () => {
|
|
setOpen((o) => {
|
|
try {
|
|
localStorage.setItem(`m3e:sec:${id}`, o ? "0" : "1");
|
|
} catch {}
|
|
return !o;
|
|
});
|
|
};
|
|
return (
|
|
<div style={{ marginBottom: 10 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6, height: 36 }}>
|
|
<button
|
|
onClick={toggle}
|
|
aria-expanded={open}
|
|
style={{
|
|
flex: 1,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: 8,
|
|
height: 36,
|
|
padding: "0 6px",
|
|
border: "none",
|
|
background: "transparent",
|
|
color: p.onSurfaceVariant,
|
|
cursor: "pointer",
|
|
borderRadius: 12,
|
|
fontSize: 12,
|
|
fontWeight: 700,
|
|
letterSpacing: 0.4,
|
|
textAlign: "left",
|
|
}}
|
|
>
|
|
<Icon name={icon} size={18} />
|
|
<span style={{ flex: 1 }}>{title}</span>
|
|
<Icon name={open ? "expand_less" : "expand_more"} size={18} />
|
|
</button>
|
|
{right}
|
|
</div>
|
|
{open && <div style={{ padding: "6px 4px 14px" }}>{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Tile({
|
|
icon,
|
|
label,
|
|
p,
|
|
onPointerDown,
|
|
onClick,
|
|
starred,
|
|
onStar,
|
|
active,
|
|
compact,
|
|
}: {
|
|
icon: string;
|
|
label: string;
|
|
p: Palette;
|
|
onPointerDown?: (e: React.PointerEvent) => void;
|
|
onClick?: () => void;
|
|
starred?: boolean;
|
|
onStar?: () => void;
|
|
active?: boolean;
|
|
compact?: boolean;
|
|
}) {
|
|
const lang = useLang();
|
|
return (
|
|
<div
|
|
className="m3-tile"
|
|
onPointerDown={onPointerDown}
|
|
onClick={onClick}
|
|
title={label}
|
|
style={{
|
|
position: "relative",
|
|
display: "flex",
|
|
flexDirection: compact ? "row" : "column",
|
|
alignItems: "center",
|
|
justifyContent: compact ? "flex-start" : "center",
|
|
gap: compact ? 10 : 6,
|
|
padding: compact ? "8px 12px" : "12px 6px 10px",
|
|
borderRadius: 16,
|
|
background: active ? p.secondaryContainer : p.surfaceContainerLow,
|
|
color: active ? p.onSecondaryContainer : p.onSurface,
|
|
cursor: onPointerDown ? "grab" : "pointer",
|
|
userSelect: "none",
|
|
touchAction: "none",
|
|
minHeight: compact ? 40 : 72,
|
|
boxSizing: "border-box",
|
|
}}
|
|
>
|
|
<Icon name={icon} size={compact ? 20 : 26} color={active ? p.onSecondaryContainer : p.primary} />
|
|
<span
|
|
style={{
|
|
fontSize: 11,
|
|
fontWeight: 500,
|
|
lineHeight: 1.2,
|
|
textAlign: "center",
|
|
color: active ? p.onSecondaryContainer : p.onSurfaceVariant,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
maxWidth: "100%",
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
{onStar && (
|
|
<button
|
|
className="m3-star"
|
|
onPointerDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onStar();
|
|
}}
|
|
title={starred ? t("removeFavorite", lang) : t("addFavorite", lang)}
|
|
aria-label={starred ? t("removeFavorite", lang) : t("addFavorite", lang)}
|
|
style={{
|
|
position: "absolute",
|
|
top: 4,
|
|
right: 4,
|
|
width: 24,
|
|
height: 24,
|
|
borderRadius: 12,
|
|
border: "none",
|
|
background: "transparent",
|
|
color: starred ? p.primary : p.outline,
|
|
cursor: "pointer",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
opacity: starred ? 1 : undefined,
|
|
}}
|
|
>
|
|
<Icon name="star" size={16} fill={starred} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** palette-role swatches; the dot shows the real color of the current theme */
|
|
export function TokenChips({
|
|
value,
|
|
onChange,
|
|
p,
|
|
none,
|
|
noneOn,
|
|
onNone,
|
|
}: {
|
|
value: ColorToken;
|
|
onChange: (t: ColorToken) => void;
|
|
p: Palette;
|
|
/** offer a "no background" chip */
|
|
none?: boolean;
|
|
noneOn?: boolean;
|
|
onNone?: () => void;
|
|
}) {
|
|
const lang = useLang();
|
|
return (
|
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
|
{none && (
|
|
<button
|
|
onClick={onNone}
|
|
title={t("noBackground", lang)}
|
|
aria-label={t("noBackground", lang)}
|
|
aria-pressed={noneOn}
|
|
className="m3-press"
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 15,
|
|
border: `1px solid ${p.outlineVariant}`,
|
|
padding: 0,
|
|
cursor: "pointer",
|
|
background: "transparent",
|
|
color: p.onSurfaceVariant,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
outline: noneOn ? `2px solid ${p.primary}` : "2px solid transparent",
|
|
outlineOffset: 2,
|
|
}}
|
|
>
|
|
<Icon name="block" size={16} />
|
|
</button>
|
|
)}
|
|
{COLOR_TOKENS.map((t) => {
|
|
const on = !noneOn && t.key === value;
|
|
const label = lang === "en" ? t.label : COLOR_TOKEN_TEXT[lang][t.key];
|
|
return (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => onChange(t.key)}
|
|
title={label}
|
|
aria-label={label}
|
|
aria-pressed={on}
|
|
className="m3-press"
|
|
style={{
|
|
width: 30,
|
|
height: 30,
|
|
borderRadius: 15,
|
|
border: `1px solid ${p.outlineVariant}`,
|
|
padding: 0,
|
|
cursor: "pointer",
|
|
background: p[t.key],
|
|
outline: on ? `2px solid ${p.primary}` : "2px solid transparent",
|
|
outlineOffset: 2,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** M3 basic dialog for a destructive confirmation. */
|
|
/** A run of buttons fused like the canvas's connected buttons: round outside, small corners where they meet. */
|
|
export function ButtonRun({ children }: { children: React.ReactNode }) {
|
|
return <div className="m3-run" style={{ display: "flex", gap: 3 }}>{children}</div>;
|
|
}
|
|
|
|
export type TidyState = "tidy" | "undo" | "done";
|
|
|
|
/** One button that reads as "Tidy", turns into "Undo tidy" right after, and is
|
|
* disabled while the screen is already tidy. */
|
|
export function TidyButton({ state, onClick, p, pill }: { state: TidyState; onClick: () => void; p: Palette; /** the toolbar version next to the zoom pill */ pill?: boolean }) {
|
|
const lang = useLang();
|
|
const done = state === "done";
|
|
const label = state === "undo" ? t("tidyUndo", lang) : state === "done" ? t("tidyDone", lang) : t("tidy", lang);
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={done}
|
|
title={label}
|
|
aria-label={label}
|
|
className="m3-press"
|
|
style={{
|
|
width: pill ? (done ? 40 : undefined) : "100%",
|
|
height: pill ? 40 : 44,
|
|
padding: done ? 0 : pill ? "0 16px 0 12px" : "0 16px",
|
|
borderRadius: pill ? 20 : 22,
|
|
border: "none",
|
|
background: done ? "transparent" : state === "undo" ? p.tertiaryContainer : p.secondaryContainer,
|
|
color: done ? p.onSurfaceVariant : state === "undo" ? p.onTertiaryContainer : p.onSecondaryContainer,
|
|
fontSize: 13,
|
|
fontWeight: 600,
|
|
cursor: done ? "default" : "pointer",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 8,
|
|
opacity: done ? 0.7 : 1,
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
<Icon name={state === "undo" ? "undo" : state === "done" ? "check" : "align_space_even"} size={done ? 22 : 20} />
|
|
{!done && label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export function ConfirmDialog({
|
|
open,
|
|
title,
|
|
body,
|
|
icon = "delete_sweep",
|
|
p,
|
|
onCancel,
|
|
onConfirm,
|
|
}: {
|
|
open: boolean;
|
|
title: string;
|
|
body: string;
|
|
icon?: string;
|
|
p: Palette;
|
|
onCancel: () => void;
|
|
onConfirm: () => void;
|
|
}) {
|
|
const lang = useLang();
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
onCancel();
|
|
}
|
|
if (e.key === "Enter") {
|
|
e.stopPropagation();
|
|
onConfirm();
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey, true);
|
|
return () => window.removeEventListener("keydown", onKey, true);
|
|
}, [open, onCancel, onConfirm]);
|
|
const btn = (label: string, primary: boolean, onClick: () => void) => (
|
|
<button
|
|
onClick={onClick}
|
|
className="m3-press"
|
|
style={{
|
|
height: 40,
|
|
padding: "0 16px",
|
|
borderRadius: 20,
|
|
border: "none",
|
|
background: primary ? p.primary : "transparent",
|
|
color: primary ? p.onPrimary : p.primary,
|
|
fontSize: 14,
|
|
fontWeight: 600,
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
key="scrim"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.16 }}
|
|
onClick={onCancel}
|
|
style={{
|
|
position: "fixed",
|
|
inset: 0,
|
|
zIndex: 600,
|
|
background: "rgba(0,0,0,0.32)",
|
|
display: "grid",
|
|
placeItems: "center",
|
|
padding: 24,
|
|
}}
|
|
>
|
|
<motion.div
|
|
role="alertdialog"
|
|
aria-modal
|
|
aria-label={title}
|
|
initial={{ scale: 0.92, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.96, opacity: 0 }}
|
|
transition={{ type: "spring", stiffness: 520, damping: 34, mass: 0.7 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
style={{
|
|
width: "min(100%, 340px)",
|
|
padding: 24,
|
|
borderRadius: 28,
|
|
background: p.surfaceContainerHigh,
|
|
color: p.onSurface,
|
|
boxShadow: "0 8px 24px rgba(0,0,0,0.18), 0 2px 6px rgba(0,0,0,0.10)",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 16,
|
|
}}
|
|
>
|
|
<div style={{ textAlign: "center", color: p.error }}>
|
|
<Icon name={icon} size={28} />
|
|
</div>
|
|
<div style={{ fontSize: 22, textAlign: "center" }}>{title}</div>
|
|
<div style={{ fontSize: 14, lineHeight: 1.5, color: p.onSurfaceVariant }}>{body}</div>
|
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
|
{btn(t("cancel", lang), false, onCancel)}
|
|
{btn(t("ok", lang), true, onConfirm)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|