From 71fb9e91490be464630bf555291f55810daea75e Mon Sep 17 00:00:00 2001 From: meh Date: Mon, 31 Aug 2026 20:55:54 +0700 Subject: [PATCH 1/2] feat(design): component palette, canvas and dual-target source emitter A Design module for composing @pathscale/ui components and reading the source they emit. One IR, two emitters, which was the H6 decision. `document.ts` holds a tree edited by pure functions and knows nothing about a target; `emit/tsx.ts` produces a plain Solid component importing from @pathscale/ui, and `emit/layout.ts` produces the `.layout.tsx` and `.recipe.ts` pair solid-layouts compiles, matching what chuzz already runs in `apps/chuzz/frontend/local-ui`. Adding a third target is a file and a line. Artboards render in the app's own document, the other H6 decision. Hit testing is `closest("[data-design-id]")`, so no bridge is involved, the control socket sees every node, and ps-qa can address them by id. The canvas takes pointer events in the capture phase and stops them there, which is what lets you select a real Button instead of pressing it. `dist/layouts.manifest.json` carries a name and a kind per component and nothing a properties panel can read, so `catalog.ts` curates 22 entries by hand. It is shaped like the recipe metadata upstream already has, and each entry records the recipe it was transcribed from, so a generator can replace it wholesale later. That generator has to land in UI and solid-layouts, outside this package. It will also need to cross-check `dist/index.d.ts`: the manifest is not an import list, and Kbd is in it without being exported from the package root. Everything drives by accessible name as well as by pointer, so the whole module is reachable from ps-qa. Tests assert the emitted source as text, since the source is the deliverable: one dropped Button emits exactly the import and the element. --- .../frontend/src/features/design/Canvas.tsx | 82 +++ .../src/features/design/DesignTab.tsx | 107 ++++ .../src/features/design/Inspector.tsx | 243 +++++++++ .../frontend/src/features/design/Palette.tsx | 77 +++ .../src/features/design/SourcePane.tsx | 127 +++++ .../frontend/src/features/design/catalog.ts | 476 ++++++++++++++++++ .../frontend/src/features/design/dnd.test.ts | 91 ++++ apps/gui/frontend/src/features/design/dnd.ts | 163 ++++++ .../src/features/design/document.test.ts | 111 ++++ .../frontend/src/features/design/document.ts | 268 ++++++++++ .../src/features/design/emit/emit.test.ts | 142 ++++++ .../src/features/design/emit/index.ts | 35 ++ .../frontend/src/features/design/emit/jsx.ts | 133 +++++ .../src/features/design/emit/layout.ts | 78 +++ .../src/features/design/emit/names.ts | 38 ++ .../frontend/src/features/design/emit/tsx.ts | 47 ++ .../src/features/design/emit/types.ts | 26 + .../frontend/src/features/design/gesture.ts | 132 +++++ .../frontend/src/features/design/render.tsx | 168 +++++++ .../gui/frontend/src/features/design/store.ts | 168 +++++++ apps/gui/frontend/src/i18n/ui/en.ts | 30 ++ apps/gui/frontend/src/i18n/ui/zh.ts | 29 ++ 22 files changed, 2771 insertions(+) create mode 100644 apps/gui/frontend/src/features/design/Canvas.tsx create mode 100644 apps/gui/frontend/src/features/design/DesignTab.tsx create mode 100644 apps/gui/frontend/src/features/design/Inspector.tsx create mode 100644 apps/gui/frontend/src/features/design/Palette.tsx create mode 100644 apps/gui/frontend/src/features/design/SourcePane.tsx create mode 100644 apps/gui/frontend/src/features/design/catalog.ts create mode 100644 apps/gui/frontend/src/features/design/dnd.test.ts create mode 100644 apps/gui/frontend/src/features/design/dnd.ts create mode 100644 apps/gui/frontend/src/features/design/document.test.ts create mode 100644 apps/gui/frontend/src/features/design/document.ts create mode 100644 apps/gui/frontend/src/features/design/emit/emit.test.ts create mode 100644 apps/gui/frontend/src/features/design/emit/index.ts create mode 100644 apps/gui/frontend/src/features/design/emit/jsx.ts create mode 100644 apps/gui/frontend/src/features/design/emit/layout.ts create mode 100644 apps/gui/frontend/src/features/design/emit/names.ts create mode 100644 apps/gui/frontend/src/features/design/emit/tsx.ts create mode 100644 apps/gui/frontend/src/features/design/emit/types.ts create mode 100644 apps/gui/frontend/src/features/design/gesture.ts create mode 100644 apps/gui/frontend/src/features/design/render.tsx create mode 100644 apps/gui/frontend/src/features/design/store.ts 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/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. */ /**