diff --git a/Cargo.toml b/Cargo.toml index 297e9c11..7513e66f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.8.48" +version = "0.8.49" edition = "2024" publish = false diff --git a/apps/gui/frontend/src/features/design/Canvas.tsx b/apps/gui/frontend/src/features/design/Canvas.tsx new file mode 100644 index 00000000..6b252753 --- /dev/null +++ b/apps/gui/frontend/src/features/design/Canvas.tsx @@ -0,0 +1,82 @@ +import type { JSX } from "@solidjs/web"; +import { For, Show } from "solid-js"; +import { tx } from "~/stores/i18n"; +import { ROOT_ID } from "./document"; +import { beginPointerDrag, hitAt } from "./gesture"; +import { DesignedNode } from "./render"; +import { design } from "./store"; + +/** + * The artboard. + * + * Renders in the app's own document, which is the H6 decision and the reason + * everything here is simple: hit testing is `closest("[data-design-id]")`, + * the control socket can see every node, and ps-qa addresses them by id. + * + * Pointer events are taken in the capture phase and stopped there. A designed + * Button is a real Button, so without that a click would press it instead of + * selecting it. Capture is the exact tool for "the canvas sees this first and + * the component never does", and it needs no `pointer-events: none` layer, + * which would have broken the hit test it was meant to serve. + */ +export function Canvas(props: { + canvas: () => HTMLElement | undefined; + setCanvas: (element: HTMLElement) => void; +}): JSX.Element { + const doc = () => design.document(); + const empty = () => doc().root.children.length === 0; + + const onPointerDown = (event: PointerEvent): void => { + event.stopPropagation(); + event.preventDefault(); + const hit = hitAt(event.clientX, event.clientY); + design.select(hit?.id ?? ROOT_ID); + if (!hit) return; + design.beginDrag({ kind: "node", nodeId: hit.id }); + beginPointerDrag({ x: event.clientX, y: event.clientY }, props.canvas, () => { + /* An unmoved press on a node is a selection, already applied above. */ + }); + }; + + return ( +
+
+ {tx("Artboard")} + + {(plan) => ( + + {plan().kind} · {plan().relativeTo} + + )} + +
+ +
+ + {tx("Drag a component here")} +
+ } + > + + {(node) => } + + + + + ); +} diff --git a/apps/gui/frontend/src/features/design/DesignTab.tsx b/apps/gui/frontend/src/features/design/DesignTab.tsx new file mode 100644 index 00000000..c8d8b725 --- /dev/null +++ b/apps/gui/frontend/src/features/design/DesignTab.tsx @@ -0,0 +1,107 @@ +import type { JSX } from "@solidjs/web"; +import { createSignal, onCleanup, onSettled, Show } from "solid-js"; +import { Button } from "~/components/Button"; +import { Icon } from "~/components/Icon"; +import { tx } from "~/stores/i18n"; +import { Canvas } from "./Canvas"; +import { Inspector } from "./Inspector"; +import { Palette } from "./Palette"; +import { SourcePane } from "./SourcePane"; +import { design } from "./store"; + +/** + * The Design tab: palette, artboard, properties, emitted source. + * + * Four views of one document. The source pane is deliberately never hidden, + * because the source is what the feature produces and everything else is a + * way of arriving at it. + */ +export function DesignTab(): JSX.Element { + const [canvas, setCanvas] = createSignal(undefined); + + onSettled(() => { + const onKey = (event: KeyboardEvent): void => { + const target = event.target; + const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement; + if (typing) return; + const accel = event.metaKey || event.ctrlKey; + if (accel && event.key.toLowerCase() === "z") { + event.preventDefault(); + if (event.shiftKey) design.redo(); + else design.undo(); + return; + } + if (event.key === "Backspace" || event.key === "Delete") { + event.preventDefault(); + design.remove(design.selectedId()); + } + }; + window.addEventListener("keydown", onKey); + onCleanup(() => window.removeEventListener("keydown", onKey)); + }); + + return ( +
+ +
+ + +
+
+ +
+ +
+
+
+ ); +} + +function Toolbar(): JSX.Element { + const history = () => design.history(); + + return ( +
+
+

{tx("Design")}

+

+ {tx("Compose @pathscale/ui components and read the source they emit")} +

+
+ + + 0}> + + +
+ ); +} diff --git a/apps/gui/frontend/src/features/design/Inspector.tsx b/apps/gui/frontend/src/features/design/Inspector.tsx new file mode 100644 index 00000000..ba502f27 --- /dev/null +++ b/apps/gui/frontend/src/features/design/Inspector.tsx @@ -0,0 +1,243 @@ +import { Input, Switch } from "@pathscale/ui"; +import type { JSX } from "@solidjs/web"; +import { For, Show } from "solid-js"; +import { Button } from "~/components/Button"; +import { Icon } from "~/components/Icon"; +import { tx } from "~/stores/i18n"; +import { lookup, type PropSpec, type PropValue } from "./catalog"; +import { type DesignNode, nodeLabel, pathTo, ROOT_TYPE } from "./document"; +import { design } from "./store"; + +/** + * The properties panel. + * + * Enums are drawn as a row of value buttons rather than a Select. Two + * reasons, and the second is the load-bearing one: every value is visible at + * a glance, which is what a design tool wants, and every value is separately + * addressable by accessible name, which is what + * `docs/ui-verification.md` requires to drive an outcome. + * + * A prop sitting at its catalog default shows as `Default` and is not stored + * on the node, so it never reaches the emitted source. Writing `variant="solid"` + * on a Button whose default is already `solid` produces an attribute that + * changes nothing, and the emitted source is the deliverable. + */ +export function Inspector(): JSX.Element { + const node = () => design.selectedNode(); + const entry = () => { + const current = node(); + return current ? lookup(current.type) : null; + }; + + return ( +
+
+

{tx("Properties")}

+ + + +
+ + + + {tx("Select a component on the canvas")}}> + {(current) => ( + }> +
+ + + + 0} + fallback={{tx("This component has no props")}} + > + + {(spec) => } + + +
+
+ )} +
+
+ ); +} + +function Hint(props: { children: JSX.Element }): JSX.Element { + return

{props.children}

; +} + +/** Where the selection sits, and a way back up to any ancestor. */ +function Breadcrumb(): JSX.Element { + const trail = () => pathTo(design.document(), design.selectedId()); + return ( +
+ + {(step, index) => ( + <> + 0}> + + + + + )} + +
+ ); +} + +/** The artboard's own field: the name every emitted symbol is built from. */ +function ArtboardFields(): JSX.Element { + /* + * A div, not a label. `Input` owns the real control and generates its own + * id, so a wrapping label associates with nothing; the Input carries the + * accessible name itself, which is also the address ps-qa drives. + */ + return ( +
+ {tx("Component name")} + + design.rename((event.currentTarget as HTMLInputElement).value) + } + /> +
+ ); +} + +/** The literal child of a text component, edited in place. */ +function TextField(props: { node: DesignNode }): JSX.Element { + return ( +
+ {tx("Text")} + + design.setText(props.node.id, (event.currentTarget as HTMLInputElement).value) + } + /> +
+ ); +} + +function PropField(props: { node: DesignNode; spec: PropSpec }): JSX.Element { + const value = (): PropValue | undefined => props.node.props[props.spec.name]; + const set = (next: PropValue | undefined): void => + design.setProp(props.node.id, props.spec.name, next); + + return ( +
+ + {props.spec.name} + + + + + + + set((event.currentTarget as HTMLInputElement).checked ? true : undefined) + } + /> + + + { + const next = (event.currentTarget as HTMLInputElement).value; + set(next === "" ? undefined : next); + }} + /> + + + { + const raw = (event.currentTarget as HTMLInputElement).value; + set(raw === "" ? undefined : Number(raw)); + }} + /> + +
+ ); +} + +function EnumField(props: { + spec: PropSpec; + value: PropValue | undefined; + nodeId: string; + onSet: (next: PropValue | undefined) => void; +}): JSX.Element { + const values = () => (props.spec.type.kind === "enum" ? props.spec.type.values : []); + const chosen = () => props.value ?? props.spec.default; + + return ( +
+ + + {(option) => ( + + )} + +
+ ); +} diff --git a/apps/gui/frontend/src/features/design/Palette.tsx b/apps/gui/frontend/src/features/design/Palette.tsx new file mode 100644 index 00000000..4af06507 --- /dev/null +++ b/apps/gui/frontend/src/features/design/Palette.tsx @@ -0,0 +1,77 @@ +import type { JSX } from "@solidjs/web"; +import { For } from "solid-js"; +import { Button } from "~/components/Button"; +import { tx } from "~/stores/i18n"; +import { type CatalogEntry, grouped } from "./catalog"; +import { beginPointerDrag } from "./gesture"; +import { design } from "./store"; + +const GROUP_LABEL = { + layout: "Layout", + display: "Display", + form: "Form", +} as const; + +/** + * The component palette. + * + * Every item is a real button with an accessible name, so the whole palette + * works by click as well as by drag. That is not a courtesy: a drag is not + * addressable from the accessibility tree, and `docs/ui-verification.md` is + * explicit that outcomes are driven by name. Click-to-append is the path QA + * drives; drag is the path a person prefers. + */ +export function Palette(props: { canvas: () => HTMLElement | undefined }): JSX.Element { + return ( +
+
+

{tx("Components")}

+

{tx("Drag onto the canvas, or click")}

+
+ + {(section) => ( +
+ + {tx(GROUP_LABEL[section.group])} + + + {(entry) => } + +
+ )} +
+
+ ); +} + +function PaletteItem(props: { + entry: CatalogEntry; + canvas: () => HTMLElement | undefined; +}): JSX.Element { + const dragging = () => { + const source = design.dragging(); + return source?.kind === "palette" && source.entry.name === props.entry.name; + }; + + return ( + + ); +} diff --git a/apps/gui/frontend/src/features/design/SourcePane.tsx b/apps/gui/frontend/src/features/design/SourcePane.tsx new file mode 100644 index 00000000..d0d9c22f --- /dev/null +++ b/apps/gui/frontend/src/features/design/SourcePane.tsx @@ -0,0 +1,127 @@ +import type { JSX } from "@solidjs/web"; +import { createSignal, For, Show } from "solid-js"; +import { Button } from "~/components/Button"; +import { Icon } from "~/components/Icon"; +import { copyText } from "~/features/project/MessageBody"; +import { whileMounted } from "~/lib/live"; +import { tx } from "~/stores/i18n"; +import { EMITTERS } from "./emit"; +import { design } from "./store"; + +/** + * The emitted source. + * + * This is the deliverable, so it is on screen the whole time rather than + * behind an Export button. A designer whose output you have to ask for is a + * designer you cannot trust: the point of watching the source change as you + * drag is that you can see immediately when it emits something you did not + * mean. + */ +export function SourcePane(): JSX.Element { + const [copied, setCopied] = createSignal(false); + const [fileIndex, setFileIndex] = createSignal(0); + const alive = whileMounted(); + + const files = () => design.emitted(); + const active = () => files()[Math.min(fileIndex(), files().length - 1)]; + + const copy = async (): Promise => { + const file = active(); + if (!file) return; + const ok = await copyText(file.source); + if (!ok) return; + alive(setCopied)(true); + window.setTimeout( + alive(() => setCopied(false)), + 1200, + ); + }; + + return ( +
+
+

{tx("Source")}

+ +
+ +
+ {tx("Emitting {count} files", { count: files().length })} +
+ +
+ + {(emitter) => ( + + )} + +
+ + 1}> +
+ + {(file, index) => ( + + )} + +
+
+ + + {(file) => ( + /* + * The name goes on a region wrapping the `
`, not on the `
`
+           * itself, which has no role to carry it. The name is how ps-qa
+           * addresses the emitted text.
+           */
+          
+
+              {file().source}
+            
+
+ )} + +
+ ); +} diff --git a/apps/gui/frontend/src/features/design/catalog.ts b/apps/gui/frontend/src/features/design/catalog.ts new file mode 100644 index 00000000..5098a95f --- /dev/null +++ b/apps/gui/frontend/src/features/design/catalog.ts @@ -0,0 +1,476 @@ +/** + * What the palette knows about `@pathscale/ui`. + * + * `dist/layouts.manifest.json` names 189 components and says nothing else + * about them: every entry is `{ "kind": "embedded" }`. A properties panel + * offering "set `variant` to `soft`" has nothing to read there, so this file + * carries the missing half by hand for a starter set. + * + * It is deliberately shaped like the metadata that already exists in the + * library's own recipes, so a generator can replace it wholesale later: + * `props` mirrors a recipe's `props` map (axis name to the values it accepts) + * and `defaults` mirrors its `defaults`. `source` records which recipe each + * entry was read from, so the generator has something to diff against. + * + * Two recipe dialects exist upstream and a generator has to read both: + * newer components call `recipe({ props, defaults })` (Button, Card, Alert, + * Skeleton, Spinner), older ones export a `CLASSES` const whose nested keys + * are the same axes (Flex, Grid, Badge, Input, Select and the rest). The + * values below were transcribed from whichever of the two each component + * ships, against @pathscale/ui 2.11.9. + * + * One warning for whoever writes that generator: the manifest is not an + * import list. `Kbd` is in it and is not exported from the package root, so + * a curated entry for it typechecked as metadata and failed to compile the + * moment the canvas tried to render it. Generate from the manifest and you + * will emit imports that do not resolve; cross-check `dist/index.d.ts`. + */ + +/** Every value a designed prop can hold. Emitted verbatim, per its kind. */ +export type PropValue = string | number | boolean; + +/** How the inspector edits a prop and how the emitter prints it. */ +export type PropKind = + | { kind: "enum"; values: readonly string[] } + | { kind: "string" } + | { kind: "number"; min?: number; max?: number; step?: number } + | { kind: "boolean" }; + +export type PropSpec = { + name: string; + type: PropKind; + /** + * The component's own default. A node whose value equals this is not + * emitted: the point of a default is that writing it changes nothing. + */ + default?: PropValue; + hint?: string; +}; + +/** What a component does with what is dropped or typed into it. */ +export type ChildPolicy = + | "none" // self-closing, e.g. Separator + | "text" // one editable string, e.g. Button + | "nodes"; // a drop target, e.g. Card.Body + +export type CatalogEntry = { + /** The element name as emitted, and the key `DesignNode.type` holds. */ + name: string; + /** The named export imported from the package. `Card.Body` imports `Card`. */ + importName: string; + group: "layout" | "display" | "form"; + summary: string; + props: readonly PropSpec[]; + children: ChildPolicy; + /** Starting text for a `text` component, so a fresh drop reads as something. */ + defaultText?: string; + /** Props written on drop. Kept minimal: a drop should look like the default. */ + initialProps?: Readonly>; + /** Where the metadata above was transcribed from, for a future generator. */ + source: string; +}; + +const FLAVORS = [ + "neutral", + "primary", + "secondary", + "accent", + "destructive", + "success", + "warning", + "info", +] as const; + +const SIZES = ["xs", "sm", "md", "lg", "xl"] as const; +const SMALL_SIZES = ["sm", "md", "lg"] as const; +const SPACING = ["none", "sm", "md", "lg", "xl"] as const; +const RADII = ["none", "sm", "md", "lg", "full"] as const; + +const enumProp = ( + name: string, + values: readonly string[], + fallback?: string, + hint?: string, +): PropSpec => ({ name, type: { kind: "enum", values }, default: fallback, hint }); + +const stringProp = (name: string, fallback?: string, hint?: string): PropSpec => ({ + name, + type: { kind: "string" }, + default: fallback, + hint, +}); + +const boolProp = (name: string, fallback = false, hint?: string): PropSpec => ({ + name, + type: { kind: "boolean" }, + default: fallback, + hint, +}); + +const numberProp = ( + name: string, + bounds: { min?: number; max?: number; step?: number }, + fallback?: number, +): PropSpec => ({ name, type: { kind: "number", ...bounds }, default: fallback }); + +/** + * The starter set: 22 entries covering layout, display and form. + * + * Curated rather than generated on purpose. The generator has to land in + * `UI/` and `solid-layouts`, outside this package, and waiting for it would + * mean shipping a designer with an empty properties panel. + */ +export const CATALOG: readonly CatalogEntry[] = [ + // ---------------------------------------------------------------- layout + { + name: "Flex", + importName: "Flex", + group: "layout", + summary: "A flex row or column that accepts drops.", + children: "nodes", + source: "flex/Flex.recipe.ts", + initialProps: { direction: "col", gap: "md" }, + props: [ + enumProp("direction", ["row", "col", "row-reverse", "col-reverse"], "row"), + enumProp("justify", ["start", "center", "end", "between", "around", "evenly"], "start"), + enumProp("align", ["start", "center", "end", "stretch", "baseline"], "stretch"), + enumProp("wrap", ["wrap", "nowrap", "wrap-reverse"], "nowrap"), + enumProp("gap", SPACING, "none"), + enumProp("paddingInline", SPACING, "none"), + enumProp("paddingBlock", SPACING, "none"), + boolProp("grow"), + boolProp("shrink"), + ], + }, + { + name: "Grid", + importName: "Grid", + group: "layout", + summary: "A column grid that accepts drops.", + children: "nodes", + source: "grid/Grid.recipe.ts", + initialProps: { cols: "2", gap: "md" }, + props: [ + enumProp("cols", ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]), + enumProp("rows", ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12"]), + enumProp("flow", ["row", "col", "row-dense", "col-dense"], "row"), + enumProp("gap", SPACING, "none"), + ], + }, + { + name: "Card", + importName: "Card", + group: "layout", + summary: "A surface. Drop Card.Header, Card.Body and Card.Footer inside.", + children: "nodes", + source: "card/Card.recipe.ts", + props: [ + enumProp("variant", ["solid", "soft", "outline", "ghost", "plain"], "plain"), + enumProp("material", ["solid", "glass"], "solid"), + enumProp("elevation", ["none", "sm", "md", "lg"], "none"), + enumProp("flavor", ["neutral", "primary", "secondary", "accent"], "neutral"), + enumProp("padding", ["none", ...SIZES], "md"), + enumProp("radius", RADII, "lg"), + boolProp("isInteractive"), + ], + }, + { + name: "Card.Header", + importName: "Card", + group: "layout", + summary: "The card's heading row.", + children: "nodes", + source: "card/Card.recipe.ts", + props: [], + }, + { + name: "Card.Body", + importName: "Card", + group: "layout", + summary: "The card's content region.", + children: "nodes", + source: "card/Card.recipe.ts", + props: [], + }, + { + name: "Card.Footer", + importName: "Card", + group: "layout", + summary: "The card's action row.", + children: "nodes", + source: "card/Card.recipe.ts", + props: [], + }, + { + name: "Separator", + importName: "Separator", + group: "layout", + summary: "A rule between sections.", + children: "none", + source: "separator/Separator.recipe.ts", + props: [ + enumProp("orientation", ["horizontal", "vertical"], "horizontal"), + enumProp("variant", ["default", "secondary", "tertiary"], "default"), + ], + }, + + // --------------------------------------------------------------- display + { + name: "Text", + importName: "Text", + group: "display", + summary: "A line or paragraph of copy.", + children: "text", + defaultText: "Text", + source: "text/Text.layout.tsx", + props: [ + enumProp("size", ["xs", "sm", "base", "lg", "xl"], "base"), + enumProp( + "variant", + ["default", "muted", "subtle", "success", "warning", "danger"], + "default", + ), + enumProp("weight", ["normal", "medium", "semibold", "bold"], "normal"), + enumProp("family", ["body", "heading", "display", "mono"], "body"), + enumProp("transform", ["none", "uppercase", "lowercase", "capitalize"], "none"), + enumProp("tracking", ["normal", "wide"], "normal"), + enumProp("leading", ["normal", "none"], "normal"), + ], + }, + { + name: "Badge", + importName: "Badge", + group: "display", + summary: "A count or status pip.", + children: "text", + defaultText: "Badge", + source: "badge/Badge.recipe.ts", + props: [ + enumProp("size", SMALL_SIZES, "md"), + enumProp("flavor", FLAVORS, "neutral"), + enumProp("variant", ["solid", "soft", "outline"], "solid"), + ], + }, + { + name: "Chip", + importName: "Chip", + group: "display", + summary: "A removable token.", + children: "text", + defaultText: "Chip", + source: "chip/Chip.recipe.ts", + props: [ + enumProp("variant", ["solid", "flat", "bordered"], "solid"), + enumProp("flavor", FLAVORS, "neutral"), + enumProp("size", SMALL_SIZES, "md"), + ], + }, + { + name: "Alert", + importName: "Alert", + group: "display", + summary: "An inline or banner message.", + children: "text", + defaultText: "Something happened.", + source: "alert/Alert.recipe.ts", + initialProps: { flavor: "info" }, + props: [ + enumProp("flavor", FLAVORS, "neutral"), + enumProp("variant", ["solid", "soft", "outline", "ghost", "plain"], "soft"), + enumProp("placement", ["inline", "banner"], "inline"), + stringProp("title"), + ], + }, + { + name: "Avatar", + importName: "Avatar", + group: "display", + summary: "A person or entity portrait.", + children: "none", + source: "avatar/Avatar.recipe.ts", + props: [ + enumProp("size", SMALL_SIZES, "md"), + enumProp("variant", ["default", "soft"], "default"), + enumProp("flavor", FLAVORS, "neutral"), + stringProp("src", undefined, "Leave empty for the initials fallback."), + stringProp("alt"), + ], + }, + { + name: "Progress", + importName: "Progress", + group: "display", + summary: "A determinate bar.", + children: "none", + source: "progress/Progress.recipe.ts", + initialProps: { value: 60 }, + props: [ + numberProp("value", { min: 0, max: 100, step: 1 }), + numberProp("max", { min: 1, step: 1 }, 100), + enumProp("size", SMALL_SIZES, "md"), + enumProp("flavor", FLAVORS, "neutral"), + ], + }, + { + name: "Spinner", + importName: "Spinner", + group: "display", + summary: "A busy indicator.", + children: "none", + source: "spinner/Spinner.recipe.ts", + props: [ + enumProp("size", SIZES, "md"), + enumProp("flavor", ["current", ...FLAVORS], "current"), + enumProp("shape", ["spinner", "dots", "ring", "ball", "bars", "infinity"], "spinner"), + stringProp("label", "Loading"), + ], + }, + { + name: "Skeleton", + importName: "Skeleton", + group: "display", + summary: "A loading placeholder.", + children: "none", + source: "skeleton/Skeleton.recipe.ts", + props: [ + enumProp("shape", ["line", "circle", "rect"], "line"), + enumProp("width", ["auto", "full", "fit", "screen"], "full"), + enumProp("size", SIZES, "md"), + enumProp("radius", RADII, "sm"), + enumProp("animation", ["shimmer", "pulse", "none"], "shimmer"), + ], + }, + { + name: "Link", + importName: "Link", + group: "display", + summary: "An anchor.", + children: "text", + defaultText: "Link", + source: "link/Link.recipe.ts", + initialProps: { href: "#" }, + props: [ + stringProp("href"), + enumProp("underline", ["always", "hover", "none"], "hover"), + boolProp("external"), + ], + }, + + // ------------------------------------------------------------------ form + { + name: "Button", + importName: "Button", + group: "form", + summary: "The call to action.", + children: "text", + defaultText: "Button", + source: "button/Button.recipe.ts", + props: [ + enumProp("variant", ["solid", "soft", "outline", "ghost", "plain"], "solid"), + enumProp("flavor", FLAVORS, "primary"), + enumProp( + "state", + ["default", "loading", "error", "invalid", "disabled", "hidden"], + "default", + ), + enumProp("size", SIZES, "sm"), + enumProp("width", ["auto", "full", "fit", "screen", "square"], "auto"), + enumProp("radius", RADII, "md"), + ], + }, + { + name: "Input", + importName: "Input", + group: "form", + summary: "A single-line field with its own label and helper.", + children: "none", + source: "input/Input.recipe.ts", + initialProps: { label: "Label", placeholder: "Type here" }, + props: [ + stringProp("label"), + stringProp("placeholder"), + stringProp("helperText"), + enumProp("size", SMALL_SIZES, "md"), + enumProp("state", ["default", "invalid", "disabled"], "default"), + boolProp("fullWidth"), + ], + }, + { + name: "Textarea", + importName: "Textarea", + group: "form", + summary: "A multi-line field.", + children: "none", + source: "textarea/Textarea.recipe.ts", + initialProps: { placeholder: "Type here" }, + props: [ + stringProp("placeholder"), + enumProp("variant", ["primary", "secondary"], "primary"), + numberProp("rows", { min: 1, max: 24, step: 1 }, 3), + boolProp("fullWidth"), + ], + }, + { + name: "Checkbox", + importName: "Checkbox", + group: "form", + summary: "A single toggle with a label.", + children: "text", + defaultText: "Checkbox", + source: "checkbox/Checkbox.recipe.ts", + props: [ + enumProp("variant", ["primary", "secondary"], "primary"), + boolProp("checked"), + boolProp("disabled"), + ], + }, + { + name: "Switch", + importName: "Switch", + group: "form", + summary: "An on/off control with a label.", + children: "text", + defaultText: "Switch", + source: "switch/Switch.recipe.ts", + props: [ + enumProp("flavor", FLAVORS, "neutral"), + enumProp("size", SMALL_SIZES, "md"), + boolProp("checked"), + boolProp("disabled"), + ], + }, + { + name: "Radio", + importName: "Radio", + group: "form", + summary: "One option in a group.", + children: "text", + defaultText: "Option", + source: "radio/Radio.recipe.ts", + initialProps: { value: "option" }, + props: [stringProp("value"), boolProp("disabled")], + }, +]; + +const BY_NAME = new Map(CATALOG.map((entry) => [entry.name, entry])); + +/** The catalog entry a node's `type` names, or null for an unknown type. */ +export function lookup(type: string): CatalogEntry | null { + return BY_NAME.get(type) ?? null; +} + +/** Whether a node of this type can hold dropped children. */ +export function acceptsChildren(type: string): boolean { + return lookup(type)?.children === "nodes"; +} + +export const CATALOG_GROUPS = ["layout", "display", "form"] as const; +export type CatalogGroup = (typeof CATALOG_GROUPS)[number]; + +/** The palette's sections, in the order it draws them. */ +export function grouped(): { group: CatalogGroup; entries: CatalogEntry[] }[] { + return CATALOG_GROUPS.map((group) => ({ + group, + entries: CATALOG.filter((entry) => entry.group === group), + })); +} diff --git a/apps/gui/frontend/src/features/design/dnd.test.ts b/apps/gui/frontend/src/features/design/dnd.test.ts new file mode 100644 index 00000000..96634671 --- /dev/null +++ b/apps/gui/frontend/src/features/design/dnd.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { lookup } from "./catalog"; +import { applyDrop, type DragSource, type Hit, resolveDrop } from "./dnd"; +import { createNode, emptyDocument, findNode, insert, ROOT_ID } from "./document"; + +function entry(name: string) { + const found = lookup(name); + if (!found) throw new Error(`no catalog entry named ${name}`); + return found; +} + +/** A Flex at the artboard's top, holding one Button. */ +function scene() { + let document = insert(emptyDocument(), ROOT_ID, 0, createNode(entry("Flex"), "f1")); + document = insert(document, "f1", 0, createNode(entry("Button"), "b1")); + return document; +} + +/** A 200x100 box at the origin, which puts its edge bands at 14px. */ +const box: Hit = { id: "f1", rect: { top: 0, left: 0, width: 200, height: 100 } }; +const button: Hit = { id: "b1", rect: { top: 20, left: 20, width: 80, height: 30 } }; + +const fromPalette: DragSource = { kind: "palette", entry: entry("Badge") }; + +describe("resolveDrop", () => { + it("drops into a container when the pointer is past its edge band", () => { + const plan = resolveDrop(scene(), box, { x: 100, y: 50 }, fromPalette); + + expect(plan).toEqual({ parentId: "f1", index: 1, kind: "into", relativeTo: "f1" }); + }); + + it("drops before a sibling when the pointer is in its top half", () => { + const plan = resolveDrop(scene(), button, { x: 60, y: 25 }, fromPalette); + + expect(plan).toEqual({ parentId: "f1", index: 0, kind: "before", relativeTo: "b1" }); + }); + + it("drops after a sibling when the pointer is in its bottom half", () => { + const plan = resolveDrop(scene(), button, { x: 60, y: 45 }, fromPalette); + + expect(plan).toEqual({ parentId: "f1", index: 1, kind: "after", relativeTo: "b1" }); + }); + + it("uses the x axis inside a row, because that is where the marker goes", () => { + let document = scene(); + document = { ...document, root: { ...document.root } }; + const row = findNode(document, "f1"); + if (row) row.props = { ...row.props, direction: "row" }; + + const plan = resolveDrop(document, button, { x: 30, y: 45 }, fromPalette); + + expect(plan?.kind).toBe("before"); + }); + + it("appends to the artboard when the pointer is over nothing", () => { + const plan = resolveDrop(scene(), null, { x: 0, y: 0 }, fromPalette); + + expect(plan).toEqual({ parentId: ROOT_ID, index: 1, kind: "into", relativeTo: ROOT_ID }); + }); + + it("refuses to land a node inside itself", () => { + const plan = resolveDrop(scene(), box, { x: 100, y: 50 }, { kind: "node", nodeId: "f1" }); + + expect(plan).toBeNull(); + }); +}); + +describe("applyDrop", () => { + it("creates the dropped component and selects it", () => { + const result = applyDrop(scene(), fromPalette, { + parentId: "f1", + index: 0, + kind: "into", + relativeTo: "f1", + }); + + expect(findNode(result.document, "f1")?.children[0].type).toBe("Badge"); + expect(result.selectedId).toBe(findNode(result.document, "f1")?.children[0].id); + }); + + it("keeps the moved node selected", () => { + const result = applyDrop( + scene(), + { kind: "node", nodeId: "b1" }, + { parentId: ROOT_ID, index: 0, kind: "before", relativeTo: "f1" }, + ); + + expect(result.selectedId).toBe("b1"); + expect(result.document.root.children.map((child) => child.id)).toEqual(["b1", "f1"]); + }); +}); diff --git a/apps/gui/frontend/src/features/design/dnd.ts b/apps/gui/frontend/src/features/design/dnd.ts new file mode 100644 index 00000000..0cc32e73 --- /dev/null +++ b/apps/gui/frontend/src/features/design/dnd.ts @@ -0,0 +1,163 @@ +/** + * Where a drag lands. + * + * The canvas renders into the app's own document, which is the H6 decision, so a + * hit test is an ordinary `elementFromPoint` walk and no bridge is involved. + * That is the reason this file can be pure: the only thing the DOM + * contributes is "which node id is under the pointer, and what is its + * rectangle", and everything after that is arithmetic over the document. + * + * The gesture itself is built on pointer events rather than HTML5 drag and + * drop. Blitz's `dragstart`/`dragover`/`drop` support is not something to + * rest a core interaction on, and pointer capture gives the same gesture + * with behaviour we own end to end. + */ + +import type { CatalogEntry } from "./catalog"; +import { + canAccept, + createNode, + type DesignDocument, + type DesignNode, + findNode, + insert, + move, + parentOf, + ROOT_ID, + subtreeIds, +} from "./document"; + +export type Rect = { top: number; left: number; width: number; height: number }; +export type Point = { x: number; y: number }; + +/** The node under the pointer, as the canvas measured it. */ +export type Hit = { id: string; rect: Rect }; + +export type DragSource = + | { kind: "palette"; entry: CatalogEntry } + | { kind: "node"; nodeId: string }; + +export type DropPlan = { + parentId: string; + index: number; + /** What the drop marker draws, and where. */ + kind: "into" | "before" | "after"; + relativeTo: string; +}; + +/** + * The band at each end of a container that means "beside me", not "inside me". + * + * A quarter of the box, clamped so that a 12px-tall Separator still has a + * usable middle and a 900px-tall Card does not claim 220px of dead zone at + * each end. + */ +function edgeBand(extent: number): number { + return Math.min(14, Math.max(4, extent * 0.25)); +} + +/** Containers laid out in a row want a left/right marker, not top/bottom. */ +function isHorizontal(node: DesignNode | null): boolean { + if (!node) return false; + if (node.type === "Grid") return true; + if (node.type !== "Flex") return false; + const direction = node.props.direction; + return direction === "row" || direction === "row-reverse"; +} + +/** + * Resolve a pointer position over a hit node into an insertion point. + * + * Returns null when the drag cannot land. Dropping a node inside itself is + * the case that matters, because it would take the branch's new parent with + * it and lose the subtree. + */ +export function resolveDrop( + document: DesignDocument, + hit: Hit | null, + point: Point, + source: DragSource, +): DropPlan | null { + const rootEnd: DropPlan = { + parentId: ROOT_ID, + index: document.root.children.length, + kind: "into", + relativeTo: ROOT_ID, + }; + + if (!hit) return rootEnd; + if (hit.id === ROOT_ID) return rootEnd; + + const node = findNode(document, hit.id); + if (!node) return rootEnd; + + if (source.kind === "node") { + if (subtreeIds(document, source.nodeId).has(hit.id)) return null; + } + + const parent = parentOf(document, hit.id); + const horizontal = isHorizontal(parent); + const start = horizontal ? hit.rect.left : hit.rect.top; + const extent = horizontal ? hit.rect.width : hit.rect.height; + const position = (horizontal ? point.x : point.y) - start; + const band = edgeBand(extent); + + if (canAccept(node.type) && position > band && position < extent - band) { + return { parentId: node.id, index: node.children.length, kind: "into", relativeTo: node.id }; + } + + if (!parent) return rootEnd; + const index = parent.children.findIndex((child) => child.id === hit.id); + const after = position >= extent / 2; + return { + parentId: parent.id, + index: after ? index + 1 : index, + kind: after ? "after" : "before", + relativeTo: hit.id, + }; +} + +/** + * Carry out a resolved drop. + * + * Returns the node that should now be selected, because both gestures end + * with the user looking at one thing: a dropped component, or the one they + * just moved. + */ +export function applyDrop( + document: DesignDocument, + source: DragSource, + plan: DropPlan, +): { document: DesignDocument; selectedId: string } { + if (source.kind === "palette") { + const node = createNode(source.entry); + return { document: insert(document, plan.parentId, plan.index, node), selectedId: node.id }; + } + return { + document: move(document, source.nodeId, plan.parentId, plan.index), + selectedId: source.nodeId, + }; +} + +/** + * Append to the end of the artboard. + * + * The palette's click-to-add path and the keyboard path both use this, so + * the designer is usable without a pointer at all, which is also what makes + * it drivable by ps-qa through the accessibility tree. + */ +export function appendToRoot( + document: DesignDocument, + entry: CatalogEntry, +): { document: DesignDocument; selectedId: string } { + return applyDrop( + document, + { kind: "palette", entry }, + { + parentId: ROOT_ID, + index: document.root.children.length, + kind: "into", + relativeTo: ROOT_ID, + }, + ); +} diff --git a/apps/gui/frontend/src/features/design/document.test.ts b/apps/gui/frontend/src/features/design/document.test.ts new file mode 100644 index 00000000..88e86659 --- /dev/null +++ b/apps/gui/frontend/src/features/design/document.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { lookup } from "./catalog"; +import { + createNode, + emptyDocument, + findNode, + insert, + move, + pathTo, + ROOT_ID, + remove, + setProp, + subtreeIds, +} from "./document"; + +function entry(name: string) { + const found = lookup(name); + if (!found) throw new Error(`no catalog entry named ${name}`); + return found; +} + +/** A Flex holding two Buttons, so ordering and reparenting have somewhere to go. */ +function nested() { + let document = insert(emptyDocument(), ROOT_ID, 0, createNode(entry("Flex"), "f1")); + document = insert(document, "f1", 0, createNode(entry("Button"), "b1")); + document = insert(document, "f1", 1, createNode(entry("Button"), "b2")); + return document; +} + +describe("insert", () => { + it("refuses a parent that does not accept children", () => { + const document = insert(emptyDocument(), ROOT_ID, 0, createNode(entry("Button"), "b1")); + const after = insert(document, "b1", 0, createNode(entry("Badge"), "g1")); + + expect(after).toBe(document); + }); + + it("clamps an index past the end rather than leaving a hole", () => { + const document = insert(emptyDocument(), ROOT_ID, 9, createNode(entry("Button"), "b1")); + + expect(document.root.children.map((child) => child.id)).toEqual(["b1"]); + }); + + it("keeps untouched branches identical, so the canvas does not rebuild them", () => { + const before = nested(); + const after = insert(before, ROOT_ID, 1, createNode(entry("Badge"), "g1")); + + expect(after.root.children[0]).toBe(before.root.children[0]); + }); +}); + +describe("move", () => { + it("reorders within one parent using the index the marker showed", () => { + const after = move(nested(), "b1", "f1", 2); + + expect(findNode(after, "f1")?.children.map((child) => child.id)).toEqual(["b2", "b1"]); + }); + + it("reparents out of a container", () => { + const after = move(nested(), "b1", ROOT_ID, 0); + + expect(after.root.children.map((child) => child.id)).toEqual(["b1", "f1"]); + expect(findNode(after, "f1")?.children.map((child) => child.id)).toEqual(["b2"]); + }); + + it("refuses to drop a node into its own subtree", () => { + let document = nested(); + document = insert(document, "f1", 2, createNode(entry("Flex"), "f2")); + const after = move(document, "f1", "f2", 0); + + expect(after).toBe(document); + }); +}); + +describe("setProp", () => { + it("stores a value that differs from the catalog default", () => { + const after = setProp(nested(), "b1", "flavor", "success"); + + expect(findNode(after, "b1")?.props.flavor).toBe("success"); + }); + + it("clears a value equal to the catalog default", () => { + let after = setProp(nested(), "b1", "flavor", "success"); + after = setProp(after, "b1", "flavor", "primary"); + + expect(findNode(after, "b1")?.props).not.toHaveProperty("flavor"); + }); + + it("clears on undefined", () => { + let after = setProp(nested(), "b1", "size", "lg"); + after = setProp(after, "b1", "size", undefined); + + expect(findNode(after, "b1")?.props).not.toHaveProperty("size"); + }); +}); + +describe("navigation", () => { + it("walks the trail from the artboard down to the node", () => { + expect(pathTo(nested(), "b2").map((node) => node.id)).toEqual([ROOT_ID, "f1", "b2"]); + }); + + it("collects a node and everything under it", () => { + expect([...subtreeIds(nested(), "f1")].sort()).toEqual(["b1", "b2", "f1"]); + }); + + it("never removes the artboard itself", () => { + const document = nested(); + + expect(remove(document, ROOT_ID)).toBe(document); + }); +}); diff --git a/apps/gui/frontend/src/features/design/document.ts b/apps/gui/frontend/src/features/design/document.ts new file mode 100644 index 00000000..e8dd0cb8 --- /dev/null +++ b/apps/gui/frontend/src/features/design/document.ts @@ -0,0 +1,268 @@ +/** + * The design document: one tree, edited by pure functions. + * + * Everything the designer does is a function from a document to a document, + * and everything it produces is a function from a document to source text. + * That split is the whole reason the emitted source is testable without a + * renderer: `insert(empty, ROOT_ID, 0, button)` then `emit(doc)` is a string + * comparison, not a screenshot. + * + * One IR, two emitters, per the H6 decision. Nothing in this file knows + * whether the answer is plain TSX or a `.layout.tsx` template; see `emit/`. + */ + +import { acceptsChildren, type CatalogEntry, lookup, type PropValue } from "./catalog"; + +export type DesignNode = { + id: string; + /** A catalog entry's `name`, or `ROOT_TYPE` for the artboard itself. */ + type: string; + /** Only props the user set. A prop left at its default is absent. */ + props: Record; + /** The literal child of a `children: "text"` component. */ + text?: string; + children: DesignNode[]; +}; + +export type DesignDocument = { + /** The emitted symbol's name, and the artboard's title. */ + name: string; + root: DesignNode; +}; + +/** + * The artboard is a fragment, not a `
`. + * + * It means a single dropped Button emits the Button and nothing else, which + * is what "dragging a Button onto an empty canvas emits exactly the import + * and the element" has to mean. A wrapper the user did not ask for would be + * a wrapper they then have to delete in their editor. Containers are dropped + * in: Flex, Grid, Card. They are not implied. + */ +export const ROOT_TYPE = "Fragment"; +export const ROOT_ID = "root"; + +let counter = 0; + +/** A fresh node id. Tests pass their own ids and never call this. */ +export function nextNodeId(): string { + counter += 1; + return `n${counter}`; +} + +/** Reset the id counter. Only for tests that assert on generated ids. */ +export function resetNodeIds(): void { + counter = 0; +} + +export function emptyDocument(name = "Untitled"): DesignDocument { + return { name, root: { id: ROOT_ID, type: ROOT_TYPE, props: {}, children: [] } }; +} + +/** A node as the palette would drop it: catalog defaults, nothing more. */ +export function createNode(entry: CatalogEntry, id: string = nextNodeId()): DesignNode { + return { + id, + type: entry.name, + props: { ...(entry.initialProps ?? {}) }, + ...(entry.children === "text" ? { text: entry.defaultText ?? entry.name } : {}), + children: [], + }; +} + +/** Whether a node of this type can hold children. The root always can. */ +export function canAccept(type: string): boolean { + return type === ROOT_TYPE || acceptsChildren(type); +} + +export function findNode(doc: DesignDocument, id: string): DesignNode | null { + return find(doc.root, id); +} + +function find(node: DesignNode, id: string): DesignNode | null { + if (node.id === id) return node; + for (const child of node.children) { + const hit = find(child, id); + if (hit) return hit; + } + return null; +} + +/** The node that holds `id`, or null for the root and for unknown ids. */ +export function parentOf(doc: DesignDocument, id: string): DesignNode | null { + if (id === doc.root.id) return null; + return findParent(doc.root, id); +} + +function findParent(node: DesignNode, id: string): DesignNode | null { + for (const child of node.children) { + if (child.id === id) return node; + const hit = findParent(child, id); + if (hit) return hit; + } + return null; +} + +/** `id` and every node beneath it, so a move can refuse to reparent into itself. */ +export function subtreeIds(doc: DesignDocument, id: string): Set { + const start = findNode(doc, id); + const ids = new Set(); + if (!start) return ids; + const stack = [start]; + while (stack.length > 0) { + const node = stack.pop() as DesignNode; + ids.add(node.id); + stack.push(...node.children); + } + return ids; +} + +/** The trail from the root down to `id`, root first, for the breadcrumb. */ +export function pathTo(doc: DesignDocument, id: string): DesignNode[] { + const trail: DesignNode[] = []; + const walk = (node: DesignNode): boolean => { + trail.push(node); + if (node.id === id) return true; + for (const child of node.children) if (walk(child)) return true; + trail.pop(); + return false; + }; + return walk(doc.root) ? trail : []; +} + +/** + * Rewrite one node in place in a fresh tree. + * + * Every edit below is expressed through this, so the untouched branches keep + * their identity and a Solid `` over children does not rebuild the + * whole canvas because a sibling's prop changed. + */ +function mapNode( + node: DesignNode, + id: string, + change: (node: DesignNode) => DesignNode, +): DesignNode { + if (node.id === id) return change(node); + let touched = false; + const children = node.children.map((child) => { + const next = mapNode(child, id, change); + if (next !== child) touched = true; + return next; + }); + return touched ? { ...node, children } : node; +} + +/** + * Put `node` into `parentId` at `index`. + * + * A parent that does not accept children, or an unknown parent, leaves the + * document alone rather than throwing: the drag layer resolves drop targets + * from the same predicate, so a rejection here means a bug upstream, not a + * user error worth an exception. + */ +export function insert( + doc: DesignDocument, + parentId: string, + index: number, + node: DesignNode, +): DesignDocument { + const parent = findNode(doc, parentId); + if (!parent || !canAccept(parent.type)) return doc; + const at = clamp(index, 0, parent.children.length); + return { + ...doc, + root: mapNode(doc.root, parentId, (target) => ({ + ...target, + children: [...target.children.slice(0, at), node, ...target.children.slice(at)], + })), + }; +} + +export function remove(doc: DesignDocument, id: string): DesignDocument { + if (id === doc.root.id) return doc; + const parent = parentOf(doc, id); + if (!parent) return doc; + return { + ...doc, + root: mapNode(doc.root, parent.id, (target) => ({ + ...target, + children: target.children.filter((child) => child.id !== id), + })), + }; +} + +/** + * Move `id` into `parentId` at `index`. + * + * Refuses to drop a node into its own subtree, the one drag gesture + * that can destroy a tree, because the moved branch would take its new + * parent with it. Indexes are resolved *after* the removal, so dragging the + * first of three children to index 2 lands it last, which is what the drop + * marker showed. + */ +export function move( + doc: DesignDocument, + id: string, + parentId: string, + index: number, +): DesignDocument { + if (id === doc.root.id) return doc; + const node = findNode(doc, id); + const parent = findNode(doc, parentId); + if (!node || !parent || !canAccept(parent.type)) return doc; + if (subtreeIds(doc, id).has(parentId)) return doc; + + const from = parentOf(doc, id); + const sameParent = from?.id === parentId; + const before = sameParent ? from.children.findIndex((child) => child.id === id) : -1; + const detached = remove(doc, id); + const target = sameParent && before >= 0 && before < index ? index - 1 : index; + return insert(detached, parentId, target, node); +} + +/** + * Set or clear one prop. + * + * `undefined` deletes it, and so does a value equal to the catalog default: + * a document that records `variant="solid"` on a Button whose default is + * already `solid` emits an attribute that changes nothing, and the source + * pane is the deliverable. + */ +export function setProp( + doc: DesignDocument, + id: string, + name: string, + value: PropValue | undefined, +): DesignDocument { + const node = findNode(doc, id); + if (!node) return doc; + const spec = lookup(node.type)?.props.find((candidate) => candidate.name === name); + const clear = value === undefined || (spec?.default !== undefined && value === spec.default); + return { + ...doc, + root: mapNode(doc.root, id, (target) => { + const props = { ...target.props }; + if (clear) delete props[name]; + else props[name] = value as PropValue; + return { ...target, props }; + }), + }; +} + +export function setText(doc: DesignDocument, id: string, text: string): DesignDocument { + return { ...doc, root: mapNode(doc.root, id, (target) => ({ ...target, text })) }; +} + +export function rename(doc: DesignDocument, name: string): DesignDocument { + return { ...doc, name }; +} + +/** How the tree panel and the canvas outline label a node. */ +export function nodeLabel(node: DesignNode): string { + if (node.type === ROOT_TYPE) return "Artboard"; + return node.text ? `${node.type} · ${node.text}` : node.type; +} + +function clamp(value: number, low: number, high: number): number { + return Math.min(high, Math.max(low, value)); +} diff --git a/apps/gui/frontend/src/features/design/emit/emit.test.ts b/apps/gui/frontend/src/features/design/emit/emit.test.ts new file mode 100644 index 00000000..72166ff0 --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/emit.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "vitest"; +import { lookup } from "../catalog"; +import { createNode, emptyDocument, insert, ROOT_ID, setProp, setText } from "../document"; +import { emit } from "./index"; + +/** + * The emitted source is the deliverable, so it is asserted as text. + * + * H6 said this exactly: dragging a Button onto an empty canvas must emit the + * import and the element, and that is a string comparison rather than a + * screenshot. Nothing in this file mounts anything. + */ + +function entry(name: string) { + const found = lookup(name); + if (!found) throw new Error(`no catalog entry named ${name}`); + return found; +} + +function withButton() { + return insert(emptyDocument(), ROOT_ID, 0, createNode(entry("Button"), "b1")); +} + +describe("the TSX emitter", () => { + it("emits exactly the import and the element for one dropped Button", () => { + const [file] = emit(withButton(), "tsx"); + + expect(file.path).toBe("Untitled.tsx"); + expect(file.source).toBe( + [ + 'import { Button } from "@pathscale/ui";', + "", + "export function Untitled() {", + " return ;", + "}", + "", + ].join("\n"), + ); + }); + + it("emits null for an empty artboard and imports nothing", () => { + const [file] = emit(emptyDocument(), "tsx"); + + expect(file.source).toContain("return null;"); + expect(file.source).not.toContain("@pathscale/ui"); + }); + + it("wraps two siblings in a fragment and never wraps one", () => { + const one = withButton(); + const two = insert(one, ROOT_ID, 1, createNode(entry("Badge"), "g1")); + + expect(emit(one, "tsx")[0].source).not.toContain("<>"); + expect(emit(two, "tsx")[0].source).toContain("<>"); + }); + + it("names the component after the artboard", () => { + const document = { ...withButton(), name: "sign-up form" }; + + expect(emit(document, "tsx")[0].source).toContain("export function SignUpForm()"); + expect(emit(document, "tsx")[0].path).toBe("SignUpForm.tsx"); + }); + + it("deduplicates the import when a component appears twice", () => { + const twice = insert(withButton(), ROOT_ID, 1, createNode(entry("Button"), "b2")); + + expect(emit(twice, "tsx")[0].source.match(/@pathscale\/ui/g)).toHaveLength(1); + expect(emit(twice, "tsx")[0].source).toContain('import { Button } from "@pathscale/ui";'); + }); + + it("imports Card once for its compound parts", () => { + let document = insert(emptyDocument(), ROOT_ID, 0, createNode(entry("Card"), "c1")); + document = insert(document, "c1", 0, createNode(entry("Card.Body"), "c2")); + + const source = emit(document, "tsx")[0].source; + expect(source).toContain('import { Card } from "@pathscale/ui";'); + expect(source).toContain(""); + }); + + it("prints each prop kind in its own JSX form", () => { + let document = withButton(); + document = setProp(document, "b1", "flavor", "destructive"); + document = insert(document, ROOT_ID, 1, createNode(entry("Progress"), "p1")); + document = insert(document, ROOT_ID, 2, createNode(entry("Card"), "k1")); + document = setProp(document, "k1", "isInteractive", true); + + const source = emit(document, "tsx")[0].source; + expect(source).toContain('flavor="destructive"'); + expect(source).toContain("value={60}"); + expect(source).toContain(""); + }); + + it("omits a prop set back to the component's own default", () => { + const document = setProp(withButton(), "b1", "variant", "solid"); + + expect(emit(document, "tsx")[0].source).not.toContain("variant"); + }); + + it("escapes text that JSX would otherwise read as syntax", () => { + const document = setText(withButton(), "b1", "Save {now}"); + + expect(emit(document, "tsx")[0].source).toContain('{"Save {now}"}'); + }); +}); + +describe("the solid-layouts emitter", () => { + it("emits a template and a recipe as a pair", () => { + const files = emit(withButton(), "layout"); + + expect(files.map((file) => file.path)).toEqual(["Untitled.layout.tsx", "Untitled.recipe.ts"]); + }); + + it("gives the template one root element carrying slot.root", () => { + const [template] = emit(withButton(), "layout"); + + expect(template.source).toBe( + [ + 'import { Button } from "@pathscale/ui";', + 'import type { Layout } from "solid-layouts";', + 'import { untitled } from "./Untitled.recipe";', + "", + "export type UntitledProps = Record;", + "", + "const Untitled: Layout = () => (", + "
", + " ", + "
", + ");", + "", + "export const UntitledLayout = Untitled;", + "export default Untitled;", + "", + ].join("\n"), + ); + }); + + it("names the recipe's component in kebab case", () => { + const [, recipe] = emit({ ...withButton(), name: "Sign up form" }, "layout"); + + expect(recipe.source).toContain('component: "sign-up-form",'); + expect(recipe.source).toContain("export const signUpForm = recipe({"); + }); +}); diff --git a/apps/gui/frontend/src/features/design/emit/index.ts b/apps/gui/frontend/src/features/design/emit/index.ts new file mode 100644 index 00000000..7594ac1c --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/index.ts @@ -0,0 +1,35 @@ +/** + * The emitter registry. + * + * H6 asked which of three answers the designer emits, and the answer taken + * was the third: one IR, both emitters. That decision only costs anything + * here. Everything upstream of this folder is target-agnostic, and adding a + * third target is adding a file and a line. + */ + +import type { DesignDocument } from "../document"; +import { layoutEmitter } from "./layout"; +import { tsxEmitter } from "./tsx"; +import type { EmitTarget, EmittedFile, Emitter } from "./types"; + +export type { EmitTarget, EmittedFile, Emitter } from "./types"; + +export const EMITTERS: readonly Emitter[] = [tsxEmitter, layoutEmitter]; + +export const DEFAULT_TARGET: EmitTarget = "tsx"; + +export function emitterFor(target: EmitTarget): Emitter { + return EMITTERS.find((emitter) => emitter.id === target) ?? tsxEmitter; +} + +export function emit(document: DesignDocument, target: EmitTarget): EmittedFile[] { + return emitterFor(target).emit(document); +} + +/** Every file of every target, for a copy-all or a future write-to-disk. */ +export function emitAll(document: DesignDocument): Record { + return { + tsx: tsxEmitter.emit(document), + layout: layoutEmitter.emit(document), + }; +} diff --git a/apps/gui/frontend/src/features/design/emit/jsx.ts b/apps/gui/frontend/src/features/design/emit/jsx.ts new file mode 100644 index 00000000..2b487881 --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/jsx.ts @@ -0,0 +1,133 @@ +/** + * Printing a design tree as JSX. + * + * Both emitters produce the same element markup and differ only in what they + * wrap it in, so the markup lives here once. Keeping it separate is also what + * makes the deliverable testable: the emitted text is a pure function of the + * document, with no renderer and no DOM anywhere near it. + */ + +import { lookup, type PropValue } from "../catalog"; +import { type DesignNode, ROOT_TYPE } from "../document"; + +export const INDENT = " "; + +/** Wrap at this width before an element's attributes go one per line. */ +const LINE_BUDGET = 92; + +/** + * A JSX attribute. + * + * `title="Save"` for plain strings, `{...}` for everything else. A string + * containing a double quote takes the expression form rather than an entity: + * `title={"He said \"no\""}` survives a round trip through a formatter, and + * `"` inside a JSX attribute reads as a bug even when it is not one. + */ +export function attribute(name: string, value: PropValue): string { + if (typeof value === "boolean") return value ? name : `${name}={false}`; + if (typeof value === "number") return `${name}={${value}}`; + if (value.includes('"')) return `${name}={${JSON.stringify(value)}}`; + return `${name}="${value}"`; +} + +/** + * A literal text child. + * + * JSX takes most text as-is. Braces and angle brackets are syntax, and + * leading or trailing whitespace is silently trimmed by the parser, so those + * cases become an explicit string expression instead. + */ +export function textChild(text: string): string { + const safe = !/[<>{}]/.test(text) && text === text.trim() && text.length > 0; + return safe ? text : `{${JSON.stringify(text)}}`; +} + +/** The props of a node, in catalog order, with unknown props appended. */ +function orderedProps(node: DesignNode): [string, PropValue][] { + const spec = lookup(node.type); + const order = new Map((spec?.props ?? []).map((prop, index) => [prop.name, index])); + return Object.entries(node.props).sort( + ([a], [b]) => + (order.get(a) ?? Number.MAX_SAFE_INTEGER) - (order.get(b) ?? Number.MAX_SAFE_INTEGER), + ); +} + +/** + * One node and its subtree as JSX lines, each already indented by `depth`. + * + * Returns lines rather than a blob so a caller can re-indent a whole subtree + * without re-walking it. + */ +export function printNode(node: DesignNode, depth: number): string[] { + const pad = INDENT.repeat(depth); + const entry = lookup(node.type); + const attrs = orderedProps(node).map(([name, value]) => attribute(name, value)); + const policy = entry?.children ?? "nodes"; + + const inner: string[] = + policy === "text" + ? node.text + ? [INDENT.repeat(depth + 1) + textChild(node.text)] + : [] + : policy === "nodes" + ? node.children.flatMap((child) => printNode(child, depth + 1)) + : []; + + const selfClosing = inner.length === 0 && policy !== "nodes"; + const openInline = `<${node.type}${attrs.length > 0 ? ` ${attrs.join(" ")}` : ""}`; + + // The whole element on one line, when it fits and its only child is text. + if (attrs.length > 0 && pad.length + openInline.length + 3 > LINE_BUDGET) { + const lines = [`${pad}<${node.type}`]; + for (const attr of attrs) lines.push(INDENT.repeat(depth + 1) + attr); + if (selfClosing) { + lines.push(`${pad}/>`); + return lines; + } + lines.push(`${pad}>`); + lines.push(...inner); + lines.push(`${pad}`); + return lines; + } + + if (selfClosing) return [`${pad}${openInline} />`]; + if (inner.length === 0) return [`${pad}${openInline}>`]; + + if (policy === "text" && inner.length === 1) { + const single = `${pad}${openInline}>${inner[0].trim()}`; + if (single.length <= LINE_BUDGET) return [single]; + } + + return [`${pad}${openInline}>`, ...inner, `${pad}`]; +} + +/** + * The artboard's children as the body of a `return`, indented under `depth`. + * + * Empty is `null` rather than an empty fragment: a component that renders + * nothing should say so. One child returns bare, with no fragment and no wrapper, + * which is the rule that makes a single dropped Button emit only a Button. + */ +export function printBody(root: DesignNode, depth: number): string[] { + if (root.type !== ROOT_TYPE) return printNode(root, depth); + if (root.children.length === 0) return [`${INDENT.repeat(depth)}null`]; + if (root.children.length === 1) return printNode(root.children[0], depth); + const pad = INDENT.repeat(depth); + return [ + `${pad}<>`, + ...root.children.flatMap((child) => printNode(child, depth + 1)), + `${pad}`, + ]; +} + +/** Every `@pathscale/ui` export the tree needs, deduplicated and sorted. */ +export function collectImports(root: DesignNode): string[] { + const names = new Set(); + const walk = (node: DesignNode): void => { + const entry = lookup(node.type); + if (entry) names.add(entry.importName); + for (const child of node.children) walk(child); + }; + walk(root); + return [...names].sort((a, b) => a.localeCompare(b)); +} diff --git a/apps/gui/frontend/src/features/design/emit/layout.ts b/apps/gui/frontend/src/features/design/emit/layout.ts new file mode 100644 index 00000000..0affea17 --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/layout.ts @@ -0,0 +1,78 @@ +/** + * The solid-layouts emitter. + * + * Output is an authored `*.layout.tsx` template plus the `*.recipe.ts` it + * refers to: the pair `solid-layouts-library` discovers and compiles. The + * model is the one chuzz already runs: `apps/chuzz/frontend/local-ui` + * authors these pairs, `bun run layouts:local` compiles the directory into + * `@chuzz/ui`, and application code imports the compiled package and never + * the source. A designer that emits into that shape drops a folder into a + * private library and the existing pipeline picks it up. + * + * One structural difference from the TSX emitter, and it is not cosmetic: + * a layout template has exactly one root element, because `slot.root` has to + * land on something. So this emitter always wraps, where the TSX emitter + * returns a lone dropped Button bare. Both are correct for their target. + */ + +import type { DesignDocument } from "../document"; +import { collectImports, INDENT, printBody } from "./jsx"; +import { camelCase, kebabCase, pascalCase } from "./names"; +import type { EmittedFile, Emitter } from "./types"; + +const PACKAGE = "@pathscale/ui"; +const TOOLCHAIN = "solid-layouts"; + +function template(document: DesignDocument): EmittedFile { + const name = pascalCase(document.name); + const recipeConst = camelCase(document.name); + const imports = collectImports(document.root); + const body = printBody(document.root, 2); + + const lines: string[] = []; + if (imports.length > 0) lines.push(`import { ${imports.join(", ")} } from "${PACKAGE}";`); + lines.push(`import type { Layout } from "${TOOLCHAIN}";`); + lines.push(`import { ${recipeConst} } from "./${name}.recipe";`); + lines.push(""); + // No designed props yet: the artboard has no parameters to expose. The + // named type still exists so a hand edit has somewhere obvious to go. + lines.push(`export type ${name}Props = Record;`); + lines.push(""); + lines.push(`const ${name}: Layout = () => (`); + lines.push(`${INDENT}
`); + for (const line of body) lines.push(line); + lines.push(`${INDENT}
`); + lines.push(");"); + lines.push(""); + lines.push(`export const ${name}Layout = ${name};`); + lines.push(`export default ${name};`); + lines.push(""); + + return { path: `${name}.layout.tsx`, language: "tsx", source: lines.join("\n") }; +} + +function recipe(document: DesignDocument): EmittedFile { + const name = pascalCase(document.name); + const recipeConst = camelCase(document.name); + const component = kebabCase(document.name); + + const source = [ + `import { recipe } from "${TOOLCHAIN}";`, + "", + `export const ${recipeConst} = recipe({`, + `${INDENT}component: "${component}",`, + `${INDENT}element: "div",`, + `${INDENT}slots: { root: { base: "${component}" } },`, + "});", + "", + ].join("\n"); + + return { path: `${name}.recipe.ts`, language: "ts", source }; +} + +export const layoutEmitter: Emitter = { + id: "layout", + label: "solid-layouts template", + summary: "A .layout.tsx and .recipe.ts pair. Compile with solid-layouts-library.", + emit: (document) => [template(document), recipe(document)], +}; diff --git a/apps/gui/frontend/src/features/design/emit/names.ts b/apps/gui/frontend/src/features/design/emit/names.ts new file mode 100644 index 00000000..eab786d1 --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/names.ts @@ -0,0 +1,38 @@ +/** Turning an artboard title into the identifiers the emitters need. */ + +const WORDS = /[^\p{L}\p{N}]+/u; + +function words(name: string): string[] { + return name + .split(WORDS) + .flatMap((part) => part.split(/(?<=\p{Ll})(?=\p{Lu})/u)) + .filter((part) => part.length > 0); +} + +/** + * `Sign-up form` becomes `SignUpForm`. + * + * A name that starts with a digit gets a leading underscore rather than a + * rejection. The artboard title is a human label and should not be able to + * make the emitter refuse to emit. + */ +export function pascalCase(name: string, fallback = "Untitled"): string { + const parts = words(name); + if (parts.length === 0) return fallback; + const joined = parts.map((part) => part[0].toUpperCase() + part.slice(1)).join(""); + return /^\p{N}/u.test(joined) ? `_${joined}` : joined; +} + +/** `Sign-up form` becomes `signUpForm`, for the recipe's exported const. */ +export function camelCase(name: string, fallback = "untitled"): string { + const pascal = pascalCase(name, ""); + if (pascal === "") return fallback; + return pascal[0].toLowerCase() + pascal.slice(1); +} + +/** `Sign-up form` becomes `sign-up-form`, for a recipe's `component` key. */ +export function kebabCase(name: string, fallback = "untitled"): string { + const parts = words(name); + if (parts.length === 0) return fallback; + return parts.map((part) => part.toLowerCase()).join("-"); +} diff --git a/apps/gui/frontend/src/features/design/emit/tsx.ts b/apps/gui/frontend/src/features/design/emit/tsx.ts new file mode 100644 index 00000000..dc0a872f --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/tsx.ts @@ -0,0 +1,47 @@ +/** + * The plain Solid emitter. + * + * Output is a component importing straight from `@pathscale/ui`, pasteable + * into any Solid app with no build step and no toolchain opinion. It is the + * emitter you reach for when the answer to "what do I do with this?" is + * "put it in my file". + */ + +import type { DesignDocument } from "../document"; +import { collectImports, INDENT, printBody } from "./jsx"; +import { pascalCase } from "./names"; +import type { EmittedFile, Emitter } from "./types"; + +const PACKAGE = "@pathscale/ui"; + +function emit(document: DesignDocument): EmittedFile[] { + const name = pascalCase(document.name); + const imports = collectImports(document.root); + const body = printBody(document.root, 1); + + const lines: string[] = []; + if (imports.length > 0) { + lines.push(`import { ${imports.join(", ")} } from "${PACKAGE}";`, ""); + } + lines.push(`export function ${name}() {`); + + // A single-line body returns inline. Wrapping one short element in + // parentheses over three lines is noise a formatter would undo anyway. + if (body.length === 1) { + lines.push(`${INDENT}return ${body[0].trim()};`); + } else { + lines.push(`${INDENT}return (`); + for (const line of body) lines.push(INDENT + line); + lines.push(`${INDENT});`); + } + lines.push("}", ""); + + return [{ path: `${name}.tsx`, language: "tsx", source: lines.join("\n") }]; +} + +export const tsxEmitter: Emitter = { + id: "tsx", + label: "Solid TSX", + summary: "A component importing from @pathscale/ui. Runs as-is.", + emit, +}; diff --git a/apps/gui/frontend/src/features/design/emit/types.ts b/apps/gui/frontend/src/features/design/emit/types.ts new file mode 100644 index 00000000..ebe3598c --- /dev/null +++ b/apps/gui/frontend/src/features/design/emit/types.ts @@ -0,0 +1,26 @@ +/** The contract both emitters satisfy, so the source pane knows neither one. */ + +import type { DesignDocument } from "../document"; + +export type EmitTarget = "tsx" | "layout"; + +export type EmittedFile = { + /** Filename as it would land on disk, relative to the component's folder. */ + path: string; + language: "tsx" | "ts"; + source: string; +}; + +export type Emitter = { + id: EmitTarget; + label: string; + summary: string; + /** + * One document in, the files that represent it out. + * + * Plural because a solid-layouts component is a template *and* a recipe; + * a single-file signature would have forced the layout emitter to inline + * the recipe and stop matching the toolchain it exists to match. + */ + emit(document: DesignDocument): EmittedFile[]; +}; diff --git a/apps/gui/frontend/src/features/design/gesture.ts b/apps/gui/frontend/src/features/design/gesture.ts new file mode 100644 index 00000000..dc24766b --- /dev/null +++ b/apps/gui/frontend/src/features/design/gesture.ts @@ -0,0 +1,132 @@ +/** + * The drag gesture, from pointerdown in the palette to the drop. + * + * Pointer events, not HTML5 drag and drop. The app ships on Blitz, whose + * `dragstart`/`dragover`/`drop` support is not something a core interaction + * should rest on, and the pointer path is identical in the browser fixture + * server and in the packaged app. + * + * The only DOM this file reads is "what is under the pointer, and what is + * its box". Everything that decides where the drop lands is in `dnd.ts`, + * which knows nothing about a document object. + */ + +import type { Hit } from "./dnd"; +import { resolveDrop } from "./dnd"; +import { design } from "./store"; + +/** Under this much movement, a pointerdown was a click, not a drag. */ +export const DRAG_THRESHOLD = 4; + +/** + * The design node under a point. + * + * `closest` on the hit element, never coordinates against a list of boxes: + * a designed component's root element carries `data-design-id`, so the + * nearest ancestor with one is the node the pointer is genuinely over, even + * when the pointer landed on some inner span the component drew itself. + */ +export function hitAt(x: number, y: number): Hit | null { + const element = window.document.elementFromPoint(x, y); + const owner = element?.closest?.("[data-design-id]"); + if (!owner) return null; + const id = owner.getAttribute("data-design-id"); + if (!id) return null; + const rect = owner.getBoundingClientRect(); + return { id, rect: { top: rect.top, left: rect.left, width: rect.width, height: rect.height } }; +} + +function withinCanvas(canvas: HTMLElement | undefined, x: number, y: number): boolean { + if (!canvas) return false; + const rect = canvas.getBoundingClientRect(); + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +export type GestureHandle = { + /** Feed a pointermove. Returns true once the gesture has become a drag. */ + move(x: number, y: number): boolean; + /** Finish. Returns "drop" when a plan landed, "click" for an unmoved press. */ + end(): "drop" | "click" | "cancel"; + cancel(): void; +}; + +/** + * Track one press. + * + * The caller has already told the store a drag has begun; this decides + * whether the press ever earned that, and keeps the hover plan current. + */ +export function trackDrag( + origin: { x: number; y: number }, + canvas: () => HTMLElement | undefined, +): GestureHandle { + let moved = false; + + return { + move(x, y) { + if (!moved && Math.hypot(x - origin.x, y - origin.y) < DRAG_THRESHOLD) return false; + moved = true; + const source = design.dragging(); + if (!source) return true; + if (!withinCanvas(canvas(), x, y)) { + design.hover(null); + return true; + } + design.hover(resolveDrop(design.document(), hitAt(x, y), { x, y }, source)); + return true; + }, + end() { + if (!moved) { + design.cancelDrag(); + return "click"; + } + const landed = design.dropPlan() !== null; + design.endDrag(); + return landed ? "drop" : "cancel"; + }, + cancel() { + design.cancelDrag(); + }, + }; +} + +/** + * Attach one press to the window and run it to completion. + * + * Window-level rather than element-level, because a drag that leaves the + * palette item, which is every drag, would otherwise stop receiving moves. + * `onClick` is the caller's click-to-append path: an unmoved press is a + * click, and the palette is fully usable with no dragging at all, which is + * also what makes it drivable through the accessibility tree. + */ +export function beginPointerDrag( + origin: { x: number; y: number }, + canvas: () => HTMLElement | undefined, + onClick: () => void, +): void { + const handle = trackDrag(origin, canvas); + + const move = (event: PointerEvent): void => { + handle.move(event.clientX, event.clientY); + }; + const finish = (): void => { + detach(); + if (handle.end() === "click") onClick(); + }; + const abort = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + detach(); + handle.cancel(); + }; + function detach(): void { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + window.removeEventListener("keydown", abort); + } + + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); + window.addEventListener("keydown", abort); +} diff --git a/apps/gui/frontend/src/features/design/render.tsx b/apps/gui/frontend/src/features/design/render.tsx new file mode 100644 index 00000000..dd211d3e --- /dev/null +++ b/apps/gui/frontend/src/features/design/render.tsx @@ -0,0 +1,168 @@ +/** + * A design node, rendered as the component it names. + * + * The preview uses the real `@pathscale/ui` components, not pictures of + * them. A designer whose canvas approximates the library is a designer that + * lies about spacing, and spacing is most of what is being designed. + * + * Artboards render in the app's own document, which is the H6 decision, so these + * are ordinary elements in the same tree as the chrome. Each carries + * `data-design-id`, which is the whole hit-testing contract: the canvas + * reads `event.target.closest("[data-design-id]")` and never touches + * coordinates. That also makes every node addressable from the + * accessibility tree for ps-qa. + */ + +import { + Alert, + Avatar, + Badge, + Button, + Card, + Checkbox, + Chip, + Flex, + Grid, + Input, + Link, + Progress, + Radio, + Separator, + Skeleton, + Spinner, + Switch, + Text, + Textarea, +} from "@pathscale/ui"; +import { Dynamic, type JSX } from "@solidjs/web"; +import { type Component, createErrorBoundary, For, Show } from "solid-js"; +import { tx } from "~/stores/i18n"; +import { lookup } from "./catalog"; +import type { DesignNode } from "./document"; + +/* biome-ignore lint/suspicious/noExplicitAny: the registry is heterogeneous by + construction: its whole job is to look a component up by a string the + document holds. The catalog is what constrains which props reach it. */ +type AnyComponent = Component; + +/** + * Catalog name to component. + * + * `Dynamic` rather than a switch of literal JSX tags, which is also why the + * ui-control id gate does not fire here: there is no `
+ +
+ +
+
{/* Keep one gutter in both panel states. The toggle stays attached to diff --git a/apps/gui/frontend/src/i18n/ui/en.ts b/apps/gui/frontend/src/i18n/ui/en.ts index 61f1669e..e06d83da 100644 --- a/apps/gui/frontend/src/i18n/ui/en.ts +++ b/apps/gui/frontend/src/i18n/ui/en.ts @@ -1037,6 +1037,36 @@ const en = { Timings: "Timings", "worst total first": "worst total first", "Nothing measured yet": "Nothing measured yet", + + // Design module + Design: "Design", + "Compose @pathscale/ui components and read the source they emit": + "Compose @pathscale/ui components and read the source they emit", + Components: "Components", + "Drag onto the canvas, or click": "Drag onto the canvas, or click", + Layout: "Layout", + Display: "Display", + Form: "Form", + Artboard: "Artboard", + "Design canvas": "Design canvas", + "Drag a component here": "Drag a component here", + "Drop target {target}": "Drop target {target}", + Properties: "Properties", + "Component name": "Component name", + Text: "Text", + Default: "Default", + "This component has no props": "This component has no props", + "Select a component on the canvas": "Select a component on the canvas", + "Delete component": "Delete component", + "Clear artboard": "Clear artboard", + Undo: "Undo", + Redo: "Redo", + Source: "Source", + "Copy source": "Copy source", + "Emitted source": "Emitted source", + "Emitting {count} files": "Emitting {count} files", + "{component} needs a parent it does not have here": + "{component} needs a parent it does not have here", } as const; export default en; diff --git a/apps/gui/frontend/src/i18n/ui/zh.ts b/apps/gui/frontend/src/i18n/ui/zh.ts index de8e4951..48f45aa8 100644 --- a/apps/gui/frontend/src/i18n/ui/zh.ts +++ b/apps/gui/frontend/src/i18n/ui/zh.ts @@ -998,6 +998,35 @@ const zh = { Timings: "耗时", "worst total first": "按总耗时降序", "Nothing measured yet": "尚无测量数据", + + // Design module + Design: "设计", + "Compose @pathscale/ui components and read the source they emit": + "组合 @pathscale/ui 组件并查看生成的源码", + Components: "组件", + "Drag onto the canvas, or click": "拖到画板上,或点击添加", + Layout: "布局", + Display: "展示", + Form: "表单", + Artboard: "画板", + "Design canvas": "设计画板", + "Drag a component here": "把组件拖到这里", + "Drop target {target}": "放置目标 {target}", + Properties: "属性", + "Component name": "组件名称", + Text: "文本", + Default: "默认", + "This component has no props": "此组件没有可设置的属性", + "Select a component on the canvas": "在画板上选择一个组件", + "Delete component": "删除组件", + "Clear artboard": "清空画板", + Undo: "撤销", + Redo: "重做", + Source: "源码", + "Copy source": "复制源码", + "Emitted source": "生成的源码", + "Emitting {count} files": "生成 {count} 个文件", + "{component} needs a parent it does not have here": "{component} 缺少它所需的父组件", } satisfies Record; export default zh; diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index ce93a99f..5a5e986d 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -2586,6 +2586,19 @@ export function createWorkspace() { focus("analytics", true); } + /** The pencil opens Design as a real tab, the same way the gauge opens Analytics. */ + function openDesign(): void { + if (!state.tabs.some((tab) => tab.kind === "design")) { + setState((d) => { + d.tabs = ((tabs) => [ + ...tabs, + { ...HOME_TAB, key: "design", kind: "design", label: "Design" }, + ])(d.tabs); + }); + } + focus("design", true); + } + /** One draft at a time: a second "+" focuses the Untitled tab already open. */ function openDraft(): void { const existing = state.tabs.find((tab) => tab.kind === "draft"); @@ -3281,6 +3294,7 @@ export function createWorkspace() { deferOnboarding, completeOnboarding, openAnalytics, + openDesign, openDraft, closeTab, setTabModel, diff --git a/apps/gui/frontend/src/types/index.ts b/apps/gui/frontend/src/types/index.ts index 5725b7ff..38f871ca 100644 --- a/apps/gui/frontend/src/types/index.ts +++ b/apps/gui/frontend/src/types/index.ts @@ -8,7 +8,7 @@ */ /** Which screen a tab shows. `home` is not closable; the rest are. */ -export type TabKind = "home" | "draft" | "settings" | "project" | "analytics"; +export type TabKind = "home" | "draft" | "settings" | "project" | "analytics" | "design"; /** One enum for both layers: a Project and its ProjectItems share it. */ /**