@ai-matrx/context-menu 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/builder.cjs +4185 -128
- package/dist/builder.cjs.map +1 -1
- package/dist/builder.d.cts +1 -1
- package/dist/builder.d.ts +1 -1
- package/dist/builder.js +4193 -136
- package/dist/builder.js.map +1 -1
- package/dist/design-store.cjs.map +1 -1
- package/dist/design-store.d.cts +1 -1
- package/dist/design-store.d.ts +1 -1
- package/dist/design-store.js.map +1 -1
- package/dist/index.cjs +202 -82
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +203 -83
- package/dist/index.js.map +1 -1
- package/dist/{model-CkLCR6WY.d.cts → model-C_7QZRE0.d.cts} +46 -4
- package/dist/{model-CkLCR6WY.d.ts → model-C_7QZRE0.d.ts} +46 -4
- package/package.json +4 -4
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/design/store.ts","../src/design/model.ts"],"sourcesContent":["// src/design/store.ts — @ai-matrx/context-menu/design-store: WHERE MENU DESIGNS ARE SAVED.\n//\n// Every Save writes a NEW VERSION of a design; nothing is overwritten, so any earlier version can be\n// opened again. Two stores answer the same interface:\n// - recordsDesignStore: a typed table in the record store (`custom.*`), so a saved design is real\n// shared data the builder's author, Claude, and the app can all read — no hand-made schema;\n// - localDesignStore: this browser's storage, for a playground with no sign-in; it says so.\n\nimport { listAppRows, removeAppRow, upsertAppRow, defineTypedTable, f } from \"@ai-matrx/records/typed-table\";\nimport type { RecordsClient } from \"@ai-matrx/records/core\";\nimport { parseDesign, serializeDesign, type MenuDesign } from \"./model\";\n\nexport const menuDesignTable = defineTypedTable({\n name: \"Menu designs\",\n slug: \"menu_designs\",\n kept_for: \"context_menu\",\n scope: \"organization\",\n owner: \"lane:CONTEXT-MENU\",\n key: \"version_key\",\n declared_in: \"aidream:apps/shared/context-menu/src/design/store.ts\",\n fields: {\n version_key: f.text({ label: \"Version key\", unique: true, required: true }),\n design_id: f.text({ label: \"Design id\", required: true }),\n name: f.text({ label: \"Name\", required: true }),\n version: f.integer({ label: \"Version\", required: true }),\n doc: f.longText({ label: \"Menu\", required: true }),\n note: f.text({ label: \"Note\" }),\n saved_by: f.text({ label: \"Saved by\" }),\n saved_at: f.datetime({ label: \"Saved\" }),\n },\n});\n\nexport interface SavedDesignVersion {\n /** The stored row (record id, or a local key). */\n rowId: string;\n designId: string;\n name: string;\n version: number;\n note: string;\n savedAt: string;\n savedBy: string;\n design: MenuDesign;\n}\n\nexport interface DesignStore {\n /** \"live\" saves to shared data; \"local\" saves to this browser only. */\n readonly kind: \"live\" | \"local\";\n /** Every saved version of every design, newest first. */\n list(): Promise<SavedDesignVersion[]>;\n /** Save `design` as its next version. Answers the saved version. */\n save(design: MenuDesign, note?: string): Promise<SavedDesignVersion>;\n /** Archive one version (restorable from the record store's archive). */\n remove(rowId: string): Promise<void>;\n}\n\nconst byNewest = (a: SavedDesignVersion, b: SavedDesignVersion) =>\n b.savedAt.localeCompare(a.savedAt) || b.version - a.version;\n\nfunction nextVersion(all: SavedDesignVersion[], designId: string): number {\n return all.filter((v) => v.designId === designId).reduce((m, v) => Math.max(m, v.version), 0) + 1;\n}\n\nexport function recordsDesignStore(client: RecordsClient, options: { organizationId: string; savedBy?: string }): DesignStore {\n const list = async (): Promise<SavedDesignVersion[]> => {\n const read = await listAppRows(client, menuDesignTable);\n if (!read.ok) throw new Error(read.error.message);\n return read.data.rows\n .map((r) => ({\n rowId: String(r._id),\n designId: String(r.design_id),\n name: String(r.name),\n version: Number(r.version),\n note: String(r.note ?? \"\"),\n savedAt: String(r.saved_at ?? \"\"),\n savedBy: String(r.saved_by ?? \"\"),\n design: parseDesign(r.doc),\n }))\n .sort(byNewest);\n };\n return {\n kind: \"live\",\n list,\n async save(design, note = \"\") {\n const all = await list();\n const version = nextVersion(all, design.designId);\n const savedAt = new Date().toISOString();\n const savedBy = options.savedBy ?? \"\";\n const wrote = await upsertAppRow(\n client,\n menuDesignTable,\n {\n version_key: `${design.designId}@${version}`,\n design_id: design.designId,\n name: design.name,\n version,\n doc: serializeDesign(design),\n note,\n saved_by: savedBy,\n saved_at: savedAt,\n },\n { organizationId: options.organizationId },\n );\n if (!wrote.ok) throw new Error(wrote.error.message);\n return { rowId: wrote.data, designId: design.designId, name: design.name, version, note, savedAt, savedBy, design };\n },\n async remove(rowId) {\n const gone = await removeAppRow(client, menuDesignTable, rowId, { organizationId: options.organizationId });\n if (!gone.ok) throw new Error(gone.error.message);\n },\n };\n}\n\nconst LOCAL_KEY = \"matrx.context-menu.designs.v1\";\n\nexport function localDesignStore(): DesignStore {\n const read = (): SavedDesignVersion[] => {\n try {\n const raw = globalThis.localStorage?.getItem(LOCAL_KEY);\n const rows = raw ? (JSON.parse(raw) as SavedDesignVersion[]) : [];\n return rows.map((r) => ({ ...r, design: parseDesign(r.design) }));\n } catch {\n return [];\n }\n };\n const write = (rows: SavedDesignVersion[]) => {\n try {\n globalThis.localStorage?.setItem(LOCAL_KEY, JSON.stringify(rows));\n } catch {\n /* storage blocked: the save still answers; the next reload will not have it */\n }\n };\n return {\n kind: \"local\",\n async list() {\n return read().sort(byNewest);\n },\n async save(design, note = \"\") {\n const all = read();\n const version = nextVersion(all, design.designId);\n const saved: SavedDesignVersion = {\n rowId: `${design.designId}@${version}`,\n designId: design.designId,\n name: design.name,\n version,\n note,\n savedAt: new Date().toISOString(),\n savedBy: \"this browser\",\n design,\n };\n write([saved, ...all]);\n return saved;\n },\n async remove(rowId) {\n write(read().filter((r) => r.rowId !== rowId));\n },\n };\n}\n","// src/design/model.ts — @ai-matrx/context-menu: THE MENU DESIGN.\n//\n// A design is the whole menu a person drew in the builder: an ordered tree of items, sections,\n// submenus and dividers, plus the few presentation choices that change how it reads. It is plain\n// JSON (no functions, no React) so it saves as one record, travels in a URL or a chat, and is the\n// exact thing the real menu is later built from. Every edit is a pure function returning a new\n// design; nothing here mutates.\n\nexport type MenuNodeKind = \"item\" | \"section\" | \"submenu\" | \"divider\" | \"slot\";\n\ninterface NodeBase {\n /** Stable within the design. */\n id: string;\n /** The catalog option this was dragged from, when it came from one. */\n catalogId?: string;\n /** A designer's note on this one entry (\"only on a table row\", \"merge with Copy\"). */\n note?: string;\n}\n\nexport interface MenuItemNode extends NodeBase {\n kind: \"item\";\n label: string;\n /** A lucide icon name (`Sparkles`, `Copy`). */\n icon?: string;\n /** Shown right-aligned (`⌘C`). */\n shortcut?: string;\n /** One short line under the label. */\n hint?: string;\n /** Shown greyed out — the design says \"this exists here but is unavailable now\". */\n disabled?: boolean;\n /** Red, for actions that remove or discard. */\n danger?: boolean;\n /** A checkbox-style toggle item. */\n toggle?: boolean;\n /** Drawn as a highlighted, AI-flavoured item. */\n ai?: boolean;\n}\n\nexport interface MenuSectionNode extends NodeBase {\n kind: \"section\";\n /** The small heading. Empty = an unlabelled group. */\n label: string;\n children: MenuNode[];\n /** Starts folded; a click on the heading opens it. */\n collapsible?: boolean;\n /** How the section's items are laid out. */\n layout?: \"list\" | \"grid\" | \"row\";\n}\n\nexport interface MenuSubmenuNode extends NodeBase {\n kind: \"submenu\";\n label: string;\n icon?: string;\n hint?: string;\n children: MenuNode[];\n}\n\nexport interface MenuDividerNode extends NodeBase {\n kind: \"divider\";\n}\n\n/**\n * A PLACEHOLDER: \"every other option of this kind goes here\". `shape: \"items\"` lays them out as rows\n * where the placeholder sits; `shape: \"submenu\"` gathers them into one submenu. `fills` names the\n * kind (a catalog group, the agent library, the page's own rows, or everything not placed).\n */\nexport interface MenuSlotNode extends NodeBase {\n kind: \"slot\";\n label: string;\n fills: string;\n shape: \"items\" | \"submenu\";\n icon?: string;\n}\n\nexport type MenuNode = MenuItemNode | MenuSectionNode | MenuSubmenuNode | MenuDividerNode | MenuSlotNode;\nexport type MenuContainerNode = MenuSectionNode | MenuSubmenuNode;\n\n/** Where the menu opens; the builder previews each the way it appears there. */\nexport const MENU_SURFACES = [\n \"text-selection\",\n \"editable-field\",\n \"table-row\",\n \"page\",\n \"chat-message\",\n \"file\",\n \"anywhere\",\n] as const;\nexport type MenuSurface = (typeof MENU_SURFACES)[number];\n\nexport interface MenuLook {\n density: \"compact\" | \"comfortable\";\n width: number;\n showIcons: boolean;\n showShortcuts: boolean;\n showHints: boolean;\n /** A search box at the top of the menu. */\n search: boolean;\n}\n\nexport const DEFAULT_LOOK: MenuLook = {\n density: \"comfortable\",\n width: 280,\n showIcons: true,\n showShortcuts: true,\n showHints: false,\n search: false,\n};\n\nexport interface MenuDesign {\n /** The design's identity; every version shares it. */\n designId: string;\n name: string;\n surface: MenuSurface;\n look: MenuLook;\n nodes: MenuNode[];\n /** Free notes for whoever builds it. */\n notes: string;\n}\n\nexport const DESIGN_FORMAT = 1;\n\n// ── ids ──────────────────────────────────────────────────────────────────────\n\nexport function newId(prefix = \"n\"): string {\n const rnd =\n typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID().slice(0, 8)\n : Math.random().toString(36).slice(2, 10);\n return `${prefix}_${rnd}`;\n}\n\nexport function emptyDesign(name = \"Untitled menu\"): MenuDesign {\n return { designId: newId(\"d\"), name, surface: \"anywhere\", look: { ...DEFAULT_LOOK }, nodes: [], notes: \"\" };\n}\n\nexport function isContainer(node: MenuNode): node is MenuContainerNode {\n return node.kind === \"section\" || node.kind === \"submenu\";\n}\n\n/** Every catalog option the design already places (so a placeholder holds only the rest). */\nexport function placedCatalogIds(nodes: readonly MenuNode[], out = new Set<string>()): Set<string> {\n for (const n of nodes) {\n if (n.catalogId) out.add(n.catalogId);\n if (isContainer(n)) placedCatalogIds(n.children, out);\n }\n return out;\n}\n\n/** How many times each catalog option appears in the design (a duplicate counts twice). */\nexport function catalogUseCounts(nodes: readonly MenuNode[], out = new Map<string, number>()): Map<string, number> {\n for (const n of nodes) {\n if (n.catalogId) out.set(n.catalogId, (out.get(n.catalogId) ?? 0) + 1);\n if (isContainer(n)) catalogUseCounts(n.children, out);\n }\n return out;\n}\n\n/** A deep copy with every id renewed — what a duplicate or a second drop of a template gets. */\nexport function cloneNode<N extends MenuNode>(node: N): N {\n const copy = { ...node, id: newId(node.kind[0]) } as N;\n if (isContainer(copy)) (copy as MenuContainerNode).children = (copy as MenuContainerNode).children.map(cloneNode);\n return copy;\n}\n\n// ── reading the tree ─────────────────────────────────────────────────────────\n\nexport interface Located {\n node: MenuNode;\n /** The container's id, or null at the top. */\n parentId: string | null;\n index: number;\n depth: number;\n}\n\nexport function locate(nodes: readonly MenuNode[], id: string, parentId: string | null = null, depth = 0): Located | null {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i] as MenuNode;\n if (node.id === id) return { node, parentId, index: i, depth };\n if (isContainer(node)) {\n const inner = locate(node.children, id, node.id, depth + 1);\n if (inner) return inner;\n }\n }\n return null;\n}\n\nexport function contains(node: MenuNode, id: string): boolean {\n if (node.id === id) return true;\n return isContainer(node) && node.children.some((c) => contains(c, id));\n}\n\nexport function countItems(nodes: readonly MenuNode[]): number {\n let n = 0;\n for (const node of nodes) {\n if (node.kind === \"item\" || node.kind === \"slot\") n += 1;\n else if (isContainer(node)) n += countItems(node.children) + (node.kind === \"submenu\" ? 1 : 0);\n }\n return n;\n}\n\n// ── editing the tree (pure) ──────────────────────────────────────────────────\n\nfunction mapChildren(nodes: readonly MenuNode[], parentId: string | null, fn: (list: MenuNode[]) => MenuNode[]): MenuNode[] {\n if (parentId === null) return fn([...nodes]);\n return nodes.map((node) => {\n if (!isContainer(node)) return node;\n if (node.id === parentId) return { ...node, children: fn([...node.children]) };\n return { ...node, children: mapChildren(node.children, parentId, fn) };\n });\n}\n\nexport function insertNode(nodes: readonly MenuNode[], parentId: string | null, index: number, node: MenuNode): MenuNode[] {\n return mapChildren(nodes, parentId, (list) => {\n list.splice(Math.max(0, Math.min(index, list.length)), 0, node);\n return list;\n });\n}\n\nexport function removeNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n return nodes\n .filter((n) => n.id !== id)\n .map((n) => (isContainer(n) ? { ...n, children: removeNode(n.children, id) } : n));\n}\n\nexport function updateNode(nodes: readonly MenuNode[], id: string, patch: Partial<MenuNode>): MenuNode[] {\n return nodes.map((n) => {\n if (n.id === id) return { ...n, ...patch, id: n.id, kind: n.kind } as MenuNode;\n if (isContainer(n)) return { ...n, children: updateNode(n.children, id, patch) };\n return n;\n });\n}\n\n/**\n * Move `id` to `index` inside `parentId`. Refuses (returns the tree unchanged) a move into itself\n * or its own descendant. `index` is in the destination list as it reads BEFORE the move.\n */\nexport function canMove(nodes: readonly MenuNode[], id: string, parentId: string | null): boolean {\n const from = locate(nodes, id);\n return Boolean(from) && (parentId === null || !contains((from as Located).node, parentId));\n}\n\nexport function moveNode(nodes: readonly MenuNode[], id: string, parentId: string | null, index: number): MenuNode[] {\n const from = locate(nodes, id);\n // A refused move answers the SAME array, so a caller can tell \"nothing happened\" by identity.\n if (!from) return nodes as MenuNode[];\n if (parentId !== null && contains(from.node, parentId)) return nodes as MenuNode[];\n let target = index;\n if (from.parentId === parentId && from.index < index) target -= 1;\n return insertNode(removeNode(nodes, id), parentId, target, from.node);\n}\n\nexport function duplicateNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n return insertNode(nodes, at.parentId, at.index + 1, cloneNode(at.node));\n}\n\n/** Wrap a node into a new section (or submenu) in its place. */\nexport function wrapNode(nodes: readonly MenuNode[], id: string, kind: \"section\" | \"submenu\"): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n const wrapper: MenuContainerNode =\n kind === \"section\"\n ? { id: newId(\"s\"), kind: \"section\", label: \"New section\", children: [at.node] }\n : { id: newId(\"m\"), kind: \"submenu\", label: \"More\", icon: \"ChevronRight\", children: [at.node] };\n return insertNode(removeNode(nodes, id), at.parentId, at.index, wrapper);\n}\n\n/** Replace a container by its children. */\nexport function unwrapNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at || !isContainer(at.node)) return [...nodes];\n let out = removeNode(nodes, id);\n at.node.children.forEach((child, i) => {\n out = insertNode(out, at.parentId, at.index + i, child);\n });\n return out;\n}\n\n// ── reading a saved design defensively ──────────────────────────────────────\n\n/** A saved document back into a design; anything unreadable becomes an empty design, never a throw. */\nexport function parseDesign(raw: unknown): MenuDesign {\n let value: unknown = raw;\n if (typeof raw === \"string\") {\n try {\n value = JSON.parse(raw);\n } catch {\n return emptyDesign(\"Unreadable design\");\n }\n }\n const v = (value ?? {}) as Partial<MenuDesign> & { design?: MenuDesign };\n const d = v.design ?? v;\n return {\n designId: typeof d.designId === \"string\" ? d.designId : newId(\"d\"),\n name: typeof d.name === \"string\" ? d.name : \"Untitled menu\",\n surface: MENU_SURFACES.includes(d.surface as MenuSurface) ? (d.surface as MenuSurface) : \"anywhere\",\n look: { ...DEFAULT_LOOK, ...(d.look ?? {}) },\n nodes: Array.isArray(d.nodes) ? (d.nodes as MenuNode[]) : [],\n notes: typeof d.notes === \"string\" ? d.notes : \"\",\n };\n}\n\nexport function serializeDesign(design: MenuDesign): string {\n return JSON.stringify({ format: DESIGN_FORMAT, design });\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/design/store.ts","../src/design/model.ts"],"sourcesContent":["// src/design/store.ts — @ai-matrx/context-menu/design-store: WHERE MENU DESIGNS ARE SAVED.\n//\n// Every Save writes a NEW VERSION of a design; nothing is overwritten, so any earlier version can be\n// opened again. Two stores answer the same interface:\n// - recordsDesignStore: a typed table in the record store (`custom.*`), so a saved design is real\n// shared data the builder's author, Claude, and the app can all read — no hand-made schema;\n// - localDesignStore: this browser's storage, for a playground with no sign-in; it says so.\n\nimport { listAppRows, removeAppRow, upsertAppRow, defineTypedTable, f } from \"@ai-matrx/records/typed-table\";\nimport type { RecordsClient } from \"@ai-matrx/records/core\";\nimport { parseDesign, serializeDesign, type MenuDesign } from \"./model\";\n\nexport const menuDesignTable = defineTypedTable({\n name: \"Menu designs\",\n slug: \"menu_designs\",\n kept_for: \"context_menu\",\n scope: \"organization\",\n owner: \"lane:CONTEXT-MENU\",\n key: \"version_key\",\n declared_in: \"aidream:apps/shared/context-menu/src/design/store.ts\",\n fields: {\n version_key: f.text({ label: \"Version key\", unique: true, required: true }),\n design_id: f.text({ label: \"Design id\", required: true }),\n name: f.text({ label: \"Name\", required: true }),\n version: f.integer({ label: \"Version\", required: true }),\n doc: f.longText({ label: \"Menu\", required: true }),\n note: f.text({ label: \"Note\" }),\n saved_by: f.text({ label: \"Saved by\" }),\n saved_at: f.datetime({ label: \"Saved\" }),\n },\n});\n\nexport interface SavedDesignVersion {\n /** The stored row (record id, or a local key). */\n rowId: string;\n designId: string;\n name: string;\n version: number;\n note: string;\n savedAt: string;\n savedBy: string;\n design: MenuDesign;\n}\n\nexport interface DesignStore {\n /** \"live\" saves to shared data; \"local\" saves to this browser only. */\n readonly kind: \"live\" | \"local\";\n /** Every saved version of every design, newest first. */\n list(): Promise<SavedDesignVersion[]>;\n /** Save `design` as its next version. Answers the saved version. */\n save(design: MenuDesign, note?: string): Promise<SavedDesignVersion>;\n /** Archive one version (restorable from the record store's archive). */\n remove(rowId: string): Promise<void>;\n}\n\nconst byNewest = (a: SavedDesignVersion, b: SavedDesignVersion) =>\n b.savedAt.localeCompare(a.savedAt) || b.version - a.version;\n\nfunction nextVersion(all: SavedDesignVersion[], designId: string): number {\n return all.filter((v) => v.designId === designId).reduce((m, v) => Math.max(m, v.version), 0) + 1;\n}\n\nexport function recordsDesignStore(client: RecordsClient, options: { organizationId: string; savedBy?: string }): DesignStore {\n const list = async (): Promise<SavedDesignVersion[]> => {\n const read = await listAppRows(client, menuDesignTable);\n if (!read.ok) throw new Error(read.error.message);\n return read.data.rows\n .map((r) => ({\n rowId: String(r._id),\n designId: String(r.design_id),\n name: String(r.name),\n version: Number(r.version),\n note: String(r.note ?? \"\"),\n savedAt: String(r.saved_at ?? \"\"),\n savedBy: String(r.saved_by ?? \"\"),\n design: parseDesign(r.doc),\n }))\n .sort(byNewest);\n };\n return {\n kind: \"live\",\n list,\n async save(design, note = \"\") {\n const all = await list();\n const version = nextVersion(all, design.designId);\n const savedAt = new Date().toISOString();\n const savedBy = options.savedBy ?? \"\";\n const wrote = await upsertAppRow(\n client,\n menuDesignTable,\n {\n version_key: `${design.designId}@${version}`,\n design_id: design.designId,\n name: design.name,\n version,\n doc: serializeDesign(design),\n note,\n saved_by: savedBy,\n saved_at: savedAt,\n },\n { organizationId: options.organizationId },\n );\n if (!wrote.ok) throw new Error(wrote.error.message);\n return { rowId: wrote.data, designId: design.designId, name: design.name, version, note, savedAt, savedBy, design };\n },\n async remove(rowId) {\n const gone = await removeAppRow(client, menuDesignTable, rowId, { organizationId: options.organizationId });\n if (!gone.ok) throw new Error(gone.error.message);\n },\n };\n}\n\nconst LOCAL_KEY = \"matrx.context-menu.designs.v1\";\n\nexport function localDesignStore(): DesignStore {\n const read = (): SavedDesignVersion[] => {\n try {\n const raw = globalThis.localStorage?.getItem(LOCAL_KEY);\n const rows = raw ? (JSON.parse(raw) as SavedDesignVersion[]) : [];\n return rows.map((r) => ({ ...r, design: parseDesign(r.design) }));\n } catch {\n return [];\n }\n };\n const write = (rows: SavedDesignVersion[]) => {\n try {\n globalThis.localStorage?.setItem(LOCAL_KEY, JSON.stringify(rows));\n } catch {\n /* storage blocked: the save still answers; the next reload will not have it */\n }\n };\n return {\n kind: \"local\",\n async list() {\n return read().sort(byNewest);\n },\n async save(design, note = \"\") {\n const all = read();\n const version = nextVersion(all, design.designId);\n const saved: SavedDesignVersion = {\n rowId: `${design.designId}@${version}`,\n designId: design.designId,\n name: design.name,\n version,\n note,\n savedAt: new Date().toISOString(),\n savedBy: \"this browser\",\n design,\n };\n write([saved, ...all]);\n return saved;\n },\n async remove(rowId) {\n write(read().filter((r) => r.rowId !== rowId));\n },\n };\n}\n","// src/design/model.ts — @ai-matrx/context-menu: THE MENU DESIGN.\n//\n// A design is the whole menu a person drew in the builder: an ordered tree of items, sections,\n// submenus and dividers, plus the few presentation choices that change how it reads. It is plain\n// JSON (no functions, no React) so it saves as one record, travels in a URL or a chat, and is the\n// exact thing the real menu is later built from. Every edit is a pure function returning a new\n// design; nothing here mutates.\n\nexport type MenuNodeKind = \"item\" | \"section\" | \"submenu\" | \"divider\" | \"slot\" | \"search\";\n\n/**\n * A row's colour, from the design system's tokens: the role inks (primary … destructive) and the\n * named hues (the chip palette). Light and dark come with the tokens.\n */\nexport const MENU_TONES = [\n \"default\",\n \"primary\",\n \"success\",\n \"warning\",\n \"info\",\n \"destructive\",\n \"sky\",\n \"blue\",\n \"indigo\",\n \"violet\",\n \"fuchsia\",\n \"rose\",\n \"orange\",\n \"amber\",\n \"lime\",\n \"emerald\",\n \"teal\",\n \"cyan\",\n \"slate\",\n] as const;\nexport type MenuTone = (typeof MENU_TONES)[number];\n\n/**\n * What the live menu does with an entry it cannot offer right now (no editable text, not an admin…):\n * - disable: shown greyed out;\n * - hide: left out;\n * - required: never unavailable — the host must always provide it.\n */\nexport const UNAVAILABLE_MODES = [\"disable\", \"hide\", \"required\"] as const;\nexport type UnavailableMode = (typeof UNAVAILABLE_MODES)[number];\n\ninterface NodeBase {\n /** Stable within the design. */\n id: string;\n /** The catalog option this was dragged from, when it came from one. */\n catalogId?: string;\n /** A designer's note on this one entry (\"only on a table row\", \"merge with Copy\"). */\n note?: string;\n /** Extra words the menu's search matches (besides the label and hint). */\n keywords?: string[];\n /** What happens when the entry is unavailable. Absent = disable. */\n unavailable?: UnavailableMode;\n /** When it is unavailable, in words (\"no editable text\", \"not an org admin\"). */\n unavailableWhen?: string;\n}\n\nexport interface MenuItemNode extends NodeBase {\n kind: \"item\";\n label: string;\n /** A lucide icon name (`Sparkles`, `Copy`). */\n icon?: string;\n /** Shown right-aligned (`⌘C`). */\n shortcut?: string;\n /** One short line under the label. */\n hint?: string;\n /** Shown greyed out — the design says \"this exists here but is unavailable now\". */\n disabled?: boolean;\n /** Red, for actions that remove or discard. */\n danger?: boolean;\n /** A checkbox-style toggle item. */\n toggle?: boolean;\n /** A setting the person flips right from the menu, drawn as a switch. */\n switch?: boolean;\n /** A checkbox or switch starts on. */\n defaultOn?: boolean;\n /** Drawn as a highlighted, AI-flavoured item. */\n ai?: boolean;\n /** The row's colour (icon; the label too with `toneLabel`). */\n tone?: MenuTone;\n toneLabel?: boolean;\n}\n\nexport interface MenuSectionNode extends NodeBase {\n kind: \"section\";\n /** The small heading. Empty = an unlabelled group. */\n label: string;\n children: MenuNode[];\n /** Starts folded; a click on the heading opens it. */\n collapsible?: boolean;\n /** How the section's items are laid out. */\n layout?: \"list\" | \"grid\" | \"row\";\n}\n\nexport interface MenuSubmenuNode extends NodeBase {\n kind: \"submenu\";\n label: string;\n icon?: string;\n hint?: string;\n tone?: MenuTone;\n toneLabel?: boolean;\n children: MenuNode[];\n}\n\n/** THE MENU'S SEARCH BOX: filters every option of the menu (labels, hints, keywords) as you type. */\nexport interface MenuSearchNode extends NodeBase {\n kind: \"search\";\n placeholder: string;\n /** Takes the keyboard when the menu opens. */\n autofocus: boolean;\n}\n\nexport interface MenuDividerNode extends NodeBase {\n kind: \"divider\";\n}\n\n/**\n * A PLACEHOLDER: \"every other option of this kind goes here\". `shape: \"items\"` lays them out as rows\n * where the placeholder sits; `shape: \"submenu\"` gathers them into one submenu. `fills` names the\n * kind (a catalog group, the agent library, the page's own rows, or everything not placed).\n */\nexport interface MenuSlotNode extends NodeBase {\n kind: \"slot\";\n label: string;\n fills: string;\n /**\n * items: rows where it sits · submenu: one submenu · feature: THE FEATURE SECTION — a section header\n * (the label), the first `featureItems` options as rows, the rest in a nested \"More\" submenu.\n */\n shape: \"items\" | \"submenu\" | \"feature\";\n icon?: string;\n /** feature shape: how many options show before \"More\". Default 4. */\n featureItems?: number;\n}\n\nexport type MenuNode = MenuItemNode | MenuSectionNode | MenuSubmenuNode | MenuDividerNode | MenuSlotNode | MenuSearchNode;\nexport type MenuContainerNode = MenuSectionNode | MenuSubmenuNode;\n\n/** Where the menu opens; the builder previews each the way it appears there. */\nexport const MENU_SURFACES = [\n \"text-selection\",\n \"editable-field\",\n \"table-row\",\n \"page\",\n \"chat-message\",\n \"file\",\n \"anywhere\",\n] as const;\nexport type MenuSurface = (typeof MENU_SURFACES)[number];\n\nexport interface MenuLook {\n density: \"compact\" | \"comfortable\";\n width: number;\n showIcons: boolean;\n showShortcuts: boolean;\n showHints: boolean;\n /** A search box at the top of the menu. */\n search: boolean;\n}\n\nexport const DEFAULT_LOOK: MenuLook = {\n density: \"comfortable\",\n width: 280,\n showIcons: true,\n showShortcuts: true,\n showHints: false,\n search: false,\n};\n\nexport interface MenuDesign {\n /** The design's identity; every version shares it. */\n designId: string;\n name: string;\n surface: MenuSurface;\n look: MenuLook;\n nodes: MenuNode[];\n /** Free notes for whoever builds it. */\n notes: string;\n}\n\nexport const DESIGN_FORMAT = 1;\n\n// ── ids ──────────────────────────────────────────────────────────────────────\n\nexport function newId(prefix = \"n\"): string {\n const rnd =\n typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID().slice(0, 8)\n : Math.random().toString(36).slice(2, 10);\n return `${prefix}_${rnd}`;\n}\n\nexport function emptyDesign(name = \"Untitled menu\"): MenuDesign {\n return { designId: newId(\"d\"), name, surface: \"anywhere\", look: { ...DEFAULT_LOOK }, nodes: [], notes: \"\" };\n}\n\nexport function isContainer(node: MenuNode): node is MenuContainerNode {\n return node.kind === \"section\" || node.kind === \"submenu\";\n}\n\n/** Every catalog option the design already places (so a placeholder holds only the rest). */\nexport function placedCatalogIds(nodes: readonly MenuNode[], out = new Set<string>()): Set<string> {\n for (const n of nodes) {\n if (n.catalogId) out.add(n.catalogId);\n if (isContainer(n)) placedCatalogIds(n.children, out);\n }\n return out;\n}\n\n/** How many times each catalog option appears in the design (a duplicate counts twice). */\nexport function catalogUseCounts(nodes: readonly MenuNode[], out = new Map<string, number>()): Map<string, number> {\n for (const n of nodes) {\n if (n.catalogId) out.set(n.catalogId, (out.get(n.catalogId) ?? 0) + 1);\n if (isContainer(n)) catalogUseCounts(n.children, out);\n }\n return out;\n}\n\n/** A deep copy with every id renewed — what a duplicate or a second drop of a template gets. */\nexport function cloneNode<N extends MenuNode>(node: N): N {\n const copy = { ...node, id: newId(node.kind[0]) } as N;\n if (isContainer(copy)) (copy as MenuContainerNode).children = (copy as MenuContainerNode).children.map(cloneNode);\n return copy;\n}\n\n// ── reading the tree ─────────────────────────────────────────────────────────\n\nexport interface Located {\n node: MenuNode;\n /** The container's id, or null at the top. */\n parentId: string | null;\n index: number;\n depth: number;\n}\n\nexport function locate(nodes: readonly MenuNode[], id: string, parentId: string | null = null, depth = 0): Located | null {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i] as MenuNode;\n if (node.id === id) return { node, parentId, index: i, depth };\n if (isContainer(node)) {\n const inner = locate(node.children, id, node.id, depth + 1);\n if (inner) return inner;\n }\n }\n return null;\n}\n\nexport function contains(node: MenuNode, id: string): boolean {\n if (node.id === id) return true;\n return isContainer(node) && node.children.some((c) => contains(c, id));\n}\n\nexport function countItems(nodes: readonly MenuNode[]): number {\n let n = 0;\n for (const node of nodes) {\n if (node.kind === \"item\" || node.kind === \"slot\") n += 1;\n else if (isContainer(node)) n += countItems(node.children) + (node.kind === \"submenu\" ? 1 : 0);\n }\n return n;\n}\n\n// ── editing the tree (pure) ──────────────────────────────────────────────────\n\nfunction mapChildren(nodes: readonly MenuNode[], parentId: string | null, fn: (list: MenuNode[]) => MenuNode[]): MenuNode[] {\n if (parentId === null) return fn([...nodes]);\n return nodes.map((node) => {\n if (!isContainer(node)) return node;\n if (node.id === parentId) return { ...node, children: fn([...node.children]) };\n return { ...node, children: mapChildren(node.children, parentId, fn) };\n });\n}\n\nexport function insertNode(nodes: readonly MenuNode[], parentId: string | null, index: number, node: MenuNode): MenuNode[] {\n return mapChildren(nodes, parentId, (list) => {\n list.splice(Math.max(0, Math.min(index, list.length)), 0, node);\n return list;\n });\n}\n\nexport function removeNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n return nodes\n .filter((n) => n.id !== id)\n .map((n) => (isContainer(n) ? { ...n, children: removeNode(n.children, id) } : n));\n}\n\nexport function updateNode(nodes: readonly MenuNode[], id: string, patch: Partial<MenuNode>): MenuNode[] {\n return nodes.map((n) => {\n if (n.id === id) return { ...n, ...patch, id: n.id, kind: n.kind } as MenuNode;\n if (isContainer(n)) return { ...n, children: updateNode(n.children, id, patch) };\n return n;\n });\n}\n\n/**\n * Move `id` to `index` inside `parentId`. Refuses (returns the tree unchanged) a move into itself\n * or its own descendant. `index` is in the destination list as it reads BEFORE the move.\n */\nexport function canMove(nodes: readonly MenuNode[], id: string, parentId: string | null): boolean {\n const from = locate(nodes, id);\n return Boolean(from) && (parentId === null || !contains((from as Located).node, parentId));\n}\n\nexport function moveNode(nodes: readonly MenuNode[], id: string, parentId: string | null, index: number): MenuNode[] {\n const from = locate(nodes, id);\n // A refused move answers the SAME array, so a caller can tell \"nothing happened\" by identity.\n if (!from) return nodes as MenuNode[];\n if (parentId !== null && contains(from.node, parentId)) return nodes as MenuNode[];\n let target = index;\n if (from.parentId === parentId && from.index < index) target -= 1;\n return insertNode(removeNode(nodes, id), parentId, target, from.node);\n}\n\nexport function duplicateNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n return insertNode(nodes, at.parentId, at.index + 1, cloneNode(at.node));\n}\n\n/** Wrap a node into a new section (or submenu) in its place. */\nexport function wrapNode(nodes: readonly MenuNode[], id: string, kind: \"section\" | \"submenu\"): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n const wrapper: MenuContainerNode =\n kind === \"section\"\n ? { id: newId(\"s\"), kind: \"section\", label: \"New section\", children: [at.node] }\n : { id: newId(\"m\"), kind: \"submenu\", label: \"More\", icon: \"ChevronRight\", children: [at.node] };\n return insertNode(removeNode(nodes, id), at.parentId, at.index, wrapper);\n}\n\n/** Replace a container by its children. */\nexport function unwrapNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at || !isContainer(at.node)) return [...nodes];\n let out = removeNode(nodes, id);\n at.node.children.forEach((child, i) => {\n out = insertNode(out, at.parentId, at.index + i, child);\n });\n return out;\n}\n\n// ── reading a saved design defensively ──────────────────────────────────────\n\n/** A saved document back into a design; anything unreadable becomes an empty design, never a throw. */\nexport function parseDesign(raw: unknown): MenuDesign {\n let value: unknown = raw;\n if (typeof raw === \"string\") {\n try {\n value = JSON.parse(raw);\n } catch {\n return emptyDesign(\"Unreadable design\");\n }\n }\n const v = (value ?? {}) as Partial<MenuDesign> & { design?: MenuDesign };\n const d = v.design ?? v;\n return {\n designId: typeof d.designId === \"string\" ? d.designId : newId(\"d\"),\n name: typeof d.name === \"string\" ? d.name : \"Untitled menu\",\n surface: MENU_SURFACES.includes(d.surface as MenuSurface) ? (d.surface as MenuSurface) : \"anywhere\",\n look: { ...DEFAULT_LOOK, ...(d.look ?? {}) },\n nodes: Array.isArray(d.nodes) ? (d.nodes as MenuNode[]) : [],\n notes: typeof d.notes === \"string\" ? d.notes : \"\",\n };\n}\n\nexport function serializeDesign(design: MenuDesign): string {\n return JSON.stringify({ format: DESIGN_FORMAT, design });\n}\n"],"mappings":"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","names":[]}
|
package/dist/design-store.d.cts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as _ai_matrx_records_typed_table from '@ai-matrx/records/typed-table';
|
|
2
2
|
import { RecordsClient } from '@ai-matrx/records/core';
|
|
3
|
-
import { M as MenuDesign } from './model-
|
|
3
|
+
import { M as MenuDesign } from './model-C_7QZRE0.cjs';
|
|
4
4
|
|
|
5
5
|
declare const menuDesignTable: _ai_matrx_records_typed_table.TypedTableDef<{
|
|
6
6
|
version_key: _ai_matrx_records_typed_table.AppField<string, true, false, true>;
|
package/dist/design-store.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as _ai_matrx_records_typed_table from '@ai-matrx/records/typed-table';
|
|
2
2
|
import { RecordsClient } from '@ai-matrx/records/core';
|
|
3
|
-
import { M as MenuDesign } from './model-
|
|
3
|
+
import { M as MenuDesign } from './model-C_7QZRE0.js';
|
|
4
4
|
|
|
5
5
|
declare const menuDesignTable: _ai_matrx_records_typed_table.TypedTableDef<{
|
|
6
6
|
version_key: _ai_matrx_records_typed_table.AppField<string, true, false, true>;
|
package/dist/design-store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/design/store.ts","../src/design/model.ts"],"sourcesContent":["// src/design/store.ts — @ai-matrx/context-menu/design-store: WHERE MENU DESIGNS ARE SAVED.\n//\n// Every Save writes a NEW VERSION of a design; nothing is overwritten, so any earlier version can be\n// opened again. Two stores answer the same interface:\n// - recordsDesignStore: a typed table in the record store (`custom.*`), so a saved design is real\n// shared data the builder's author, Claude, and the app can all read — no hand-made schema;\n// - localDesignStore: this browser's storage, for a playground with no sign-in; it says so.\n\nimport { listAppRows, removeAppRow, upsertAppRow, defineTypedTable, f } from \"@ai-matrx/records/typed-table\";\nimport type { RecordsClient } from \"@ai-matrx/records/core\";\nimport { parseDesign, serializeDesign, type MenuDesign } from \"./model\";\n\nexport const menuDesignTable = defineTypedTable({\n name: \"Menu designs\",\n slug: \"menu_designs\",\n kept_for: \"context_menu\",\n scope: \"organization\",\n owner: \"lane:CONTEXT-MENU\",\n key: \"version_key\",\n declared_in: \"aidream:apps/shared/context-menu/src/design/store.ts\",\n fields: {\n version_key: f.text({ label: \"Version key\", unique: true, required: true }),\n design_id: f.text({ label: \"Design id\", required: true }),\n name: f.text({ label: \"Name\", required: true }),\n version: f.integer({ label: \"Version\", required: true }),\n doc: f.longText({ label: \"Menu\", required: true }),\n note: f.text({ label: \"Note\" }),\n saved_by: f.text({ label: \"Saved by\" }),\n saved_at: f.datetime({ label: \"Saved\" }),\n },\n});\n\nexport interface SavedDesignVersion {\n /** The stored row (record id, or a local key). */\n rowId: string;\n designId: string;\n name: string;\n version: number;\n note: string;\n savedAt: string;\n savedBy: string;\n design: MenuDesign;\n}\n\nexport interface DesignStore {\n /** \"live\" saves to shared data; \"local\" saves to this browser only. */\n readonly kind: \"live\" | \"local\";\n /** Every saved version of every design, newest first. */\n list(): Promise<SavedDesignVersion[]>;\n /** Save `design` as its next version. Answers the saved version. */\n save(design: MenuDesign, note?: string): Promise<SavedDesignVersion>;\n /** Archive one version (restorable from the record store's archive). */\n remove(rowId: string): Promise<void>;\n}\n\nconst byNewest = (a: SavedDesignVersion, b: SavedDesignVersion) =>\n b.savedAt.localeCompare(a.savedAt) || b.version - a.version;\n\nfunction nextVersion(all: SavedDesignVersion[], designId: string): number {\n return all.filter((v) => v.designId === designId).reduce((m, v) => Math.max(m, v.version), 0) + 1;\n}\n\nexport function recordsDesignStore(client: RecordsClient, options: { organizationId: string; savedBy?: string }): DesignStore {\n const list = async (): Promise<SavedDesignVersion[]> => {\n const read = await listAppRows(client, menuDesignTable);\n if (!read.ok) throw new Error(read.error.message);\n return read.data.rows\n .map((r) => ({\n rowId: String(r._id),\n designId: String(r.design_id),\n name: String(r.name),\n version: Number(r.version),\n note: String(r.note ?? \"\"),\n savedAt: String(r.saved_at ?? \"\"),\n savedBy: String(r.saved_by ?? \"\"),\n design: parseDesign(r.doc),\n }))\n .sort(byNewest);\n };\n return {\n kind: \"live\",\n list,\n async save(design, note = \"\") {\n const all = await list();\n const version = nextVersion(all, design.designId);\n const savedAt = new Date().toISOString();\n const savedBy = options.savedBy ?? \"\";\n const wrote = await upsertAppRow(\n client,\n menuDesignTable,\n {\n version_key: `${design.designId}@${version}`,\n design_id: design.designId,\n name: design.name,\n version,\n doc: serializeDesign(design),\n note,\n saved_by: savedBy,\n saved_at: savedAt,\n },\n { organizationId: options.organizationId },\n );\n if (!wrote.ok) throw new Error(wrote.error.message);\n return { rowId: wrote.data, designId: design.designId, name: design.name, version, note, savedAt, savedBy, design };\n },\n async remove(rowId) {\n const gone = await removeAppRow(client, menuDesignTable, rowId, { organizationId: options.organizationId });\n if (!gone.ok) throw new Error(gone.error.message);\n },\n };\n}\n\nconst LOCAL_KEY = \"matrx.context-menu.designs.v1\";\n\nexport function localDesignStore(): DesignStore {\n const read = (): SavedDesignVersion[] => {\n try {\n const raw = globalThis.localStorage?.getItem(LOCAL_KEY);\n const rows = raw ? (JSON.parse(raw) as SavedDesignVersion[]) : [];\n return rows.map((r) => ({ ...r, design: parseDesign(r.design) }));\n } catch {\n return [];\n }\n };\n const write = (rows: SavedDesignVersion[]) => {\n try {\n globalThis.localStorage?.setItem(LOCAL_KEY, JSON.stringify(rows));\n } catch {\n /* storage blocked: the save still answers; the next reload will not have it */\n }\n };\n return {\n kind: \"local\",\n async list() {\n return read().sort(byNewest);\n },\n async save(design, note = \"\") {\n const all = read();\n const version = nextVersion(all, design.designId);\n const saved: SavedDesignVersion = {\n rowId: `${design.designId}@${version}`,\n designId: design.designId,\n name: design.name,\n version,\n note,\n savedAt: new Date().toISOString(),\n savedBy: \"this browser\",\n design,\n };\n write([saved, ...all]);\n return saved;\n },\n async remove(rowId) {\n write(read().filter((r) => r.rowId !== rowId));\n },\n };\n}\n","// src/design/model.ts — @ai-matrx/context-menu: THE MENU DESIGN.\n//\n// A design is the whole menu a person drew in the builder: an ordered tree of items, sections,\n// submenus and dividers, plus the few presentation choices that change how it reads. It is plain\n// JSON (no functions, no React) so it saves as one record, travels in a URL or a chat, and is the\n// exact thing the real menu is later built from. Every edit is a pure function returning a new\n// design; nothing here mutates.\n\nexport type MenuNodeKind = \"item\" | \"section\" | \"submenu\" | \"divider\" | \"slot\";\n\ninterface NodeBase {\n /** Stable within the design. */\n id: string;\n /** The catalog option this was dragged from, when it came from one. */\n catalogId?: string;\n /** A designer's note on this one entry (\"only on a table row\", \"merge with Copy\"). */\n note?: string;\n}\n\nexport interface MenuItemNode extends NodeBase {\n kind: \"item\";\n label: string;\n /** A lucide icon name (`Sparkles`, `Copy`). */\n icon?: string;\n /** Shown right-aligned (`⌘C`). */\n shortcut?: string;\n /** One short line under the label. */\n hint?: string;\n /** Shown greyed out — the design says \"this exists here but is unavailable now\". */\n disabled?: boolean;\n /** Red, for actions that remove or discard. */\n danger?: boolean;\n /** A checkbox-style toggle item. */\n toggle?: boolean;\n /** Drawn as a highlighted, AI-flavoured item. */\n ai?: boolean;\n}\n\nexport interface MenuSectionNode extends NodeBase {\n kind: \"section\";\n /** The small heading. Empty = an unlabelled group. */\n label: string;\n children: MenuNode[];\n /** Starts folded; a click on the heading opens it. */\n collapsible?: boolean;\n /** How the section's items are laid out. */\n layout?: \"list\" | \"grid\" | \"row\";\n}\n\nexport interface MenuSubmenuNode extends NodeBase {\n kind: \"submenu\";\n label: string;\n icon?: string;\n hint?: string;\n children: MenuNode[];\n}\n\nexport interface MenuDividerNode extends NodeBase {\n kind: \"divider\";\n}\n\n/**\n * A PLACEHOLDER: \"every other option of this kind goes here\". `shape: \"items\"` lays them out as rows\n * where the placeholder sits; `shape: \"submenu\"` gathers them into one submenu. `fills` names the\n * kind (a catalog group, the agent library, the page's own rows, or everything not placed).\n */\nexport interface MenuSlotNode extends NodeBase {\n kind: \"slot\";\n label: string;\n fills: string;\n shape: \"items\" | \"submenu\";\n icon?: string;\n}\n\nexport type MenuNode = MenuItemNode | MenuSectionNode | MenuSubmenuNode | MenuDividerNode | MenuSlotNode;\nexport type MenuContainerNode = MenuSectionNode | MenuSubmenuNode;\n\n/** Where the menu opens; the builder previews each the way it appears there. */\nexport const MENU_SURFACES = [\n \"text-selection\",\n \"editable-field\",\n \"table-row\",\n \"page\",\n \"chat-message\",\n \"file\",\n \"anywhere\",\n] as const;\nexport type MenuSurface = (typeof MENU_SURFACES)[number];\n\nexport interface MenuLook {\n density: \"compact\" | \"comfortable\";\n width: number;\n showIcons: boolean;\n showShortcuts: boolean;\n showHints: boolean;\n /** A search box at the top of the menu. */\n search: boolean;\n}\n\nexport const DEFAULT_LOOK: MenuLook = {\n density: \"comfortable\",\n width: 280,\n showIcons: true,\n showShortcuts: true,\n showHints: false,\n search: false,\n};\n\nexport interface MenuDesign {\n /** The design's identity; every version shares it. */\n designId: string;\n name: string;\n surface: MenuSurface;\n look: MenuLook;\n nodes: MenuNode[];\n /** Free notes for whoever builds it. */\n notes: string;\n}\n\nexport const DESIGN_FORMAT = 1;\n\n// ── ids ──────────────────────────────────────────────────────────────────────\n\nexport function newId(prefix = \"n\"): string {\n const rnd =\n typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID().slice(0, 8)\n : Math.random().toString(36).slice(2, 10);\n return `${prefix}_${rnd}`;\n}\n\nexport function emptyDesign(name = \"Untitled menu\"): MenuDesign {\n return { designId: newId(\"d\"), name, surface: \"anywhere\", look: { ...DEFAULT_LOOK }, nodes: [], notes: \"\" };\n}\n\nexport function isContainer(node: MenuNode): node is MenuContainerNode {\n return node.kind === \"section\" || node.kind === \"submenu\";\n}\n\n/** Every catalog option the design already places (so a placeholder holds only the rest). */\nexport function placedCatalogIds(nodes: readonly MenuNode[], out = new Set<string>()): Set<string> {\n for (const n of nodes) {\n if (n.catalogId) out.add(n.catalogId);\n if (isContainer(n)) placedCatalogIds(n.children, out);\n }\n return out;\n}\n\n/** How many times each catalog option appears in the design (a duplicate counts twice). */\nexport function catalogUseCounts(nodes: readonly MenuNode[], out = new Map<string, number>()): Map<string, number> {\n for (const n of nodes) {\n if (n.catalogId) out.set(n.catalogId, (out.get(n.catalogId) ?? 0) + 1);\n if (isContainer(n)) catalogUseCounts(n.children, out);\n }\n return out;\n}\n\n/** A deep copy with every id renewed — what a duplicate or a second drop of a template gets. */\nexport function cloneNode<N extends MenuNode>(node: N): N {\n const copy = { ...node, id: newId(node.kind[0]) } as N;\n if (isContainer(copy)) (copy as MenuContainerNode).children = (copy as MenuContainerNode).children.map(cloneNode);\n return copy;\n}\n\n// ── reading the tree ─────────────────────────────────────────────────────────\n\nexport interface Located {\n node: MenuNode;\n /** The container's id, or null at the top. */\n parentId: string | null;\n index: number;\n depth: number;\n}\n\nexport function locate(nodes: readonly MenuNode[], id: string, parentId: string | null = null, depth = 0): Located | null {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i] as MenuNode;\n if (node.id === id) return { node, parentId, index: i, depth };\n if (isContainer(node)) {\n const inner = locate(node.children, id, node.id, depth + 1);\n if (inner) return inner;\n }\n }\n return null;\n}\n\nexport function contains(node: MenuNode, id: string): boolean {\n if (node.id === id) return true;\n return isContainer(node) && node.children.some((c) => contains(c, id));\n}\n\nexport function countItems(nodes: readonly MenuNode[]): number {\n let n = 0;\n for (const node of nodes) {\n if (node.kind === \"item\" || node.kind === \"slot\") n += 1;\n else if (isContainer(node)) n += countItems(node.children) + (node.kind === \"submenu\" ? 1 : 0);\n }\n return n;\n}\n\n// ── editing the tree (pure) ──────────────────────────────────────────────────\n\nfunction mapChildren(nodes: readonly MenuNode[], parentId: string | null, fn: (list: MenuNode[]) => MenuNode[]): MenuNode[] {\n if (parentId === null) return fn([...nodes]);\n return nodes.map((node) => {\n if (!isContainer(node)) return node;\n if (node.id === parentId) return { ...node, children: fn([...node.children]) };\n return { ...node, children: mapChildren(node.children, parentId, fn) };\n });\n}\n\nexport function insertNode(nodes: readonly MenuNode[], parentId: string | null, index: number, node: MenuNode): MenuNode[] {\n return mapChildren(nodes, parentId, (list) => {\n list.splice(Math.max(0, Math.min(index, list.length)), 0, node);\n return list;\n });\n}\n\nexport function removeNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n return nodes\n .filter((n) => n.id !== id)\n .map((n) => (isContainer(n) ? { ...n, children: removeNode(n.children, id) } : n));\n}\n\nexport function updateNode(nodes: readonly MenuNode[], id: string, patch: Partial<MenuNode>): MenuNode[] {\n return nodes.map((n) => {\n if (n.id === id) return { ...n, ...patch, id: n.id, kind: n.kind } as MenuNode;\n if (isContainer(n)) return { ...n, children: updateNode(n.children, id, patch) };\n return n;\n });\n}\n\n/**\n * Move `id` to `index` inside `parentId`. Refuses (returns the tree unchanged) a move into itself\n * or its own descendant. `index` is in the destination list as it reads BEFORE the move.\n */\nexport function canMove(nodes: readonly MenuNode[], id: string, parentId: string | null): boolean {\n const from = locate(nodes, id);\n return Boolean(from) && (parentId === null || !contains((from as Located).node, parentId));\n}\n\nexport function moveNode(nodes: readonly MenuNode[], id: string, parentId: string | null, index: number): MenuNode[] {\n const from = locate(nodes, id);\n // A refused move answers the SAME array, so a caller can tell \"nothing happened\" by identity.\n if (!from) return nodes as MenuNode[];\n if (parentId !== null && contains(from.node, parentId)) return nodes as MenuNode[];\n let target = index;\n if (from.parentId === parentId && from.index < index) target -= 1;\n return insertNode(removeNode(nodes, id), parentId, target, from.node);\n}\n\nexport function duplicateNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n return insertNode(nodes, at.parentId, at.index + 1, cloneNode(at.node));\n}\n\n/** Wrap a node into a new section (or submenu) in its place. */\nexport function wrapNode(nodes: readonly MenuNode[], id: string, kind: \"section\" | \"submenu\"): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n const wrapper: MenuContainerNode =\n kind === \"section\"\n ? { id: newId(\"s\"), kind: \"section\", label: \"New section\", children: [at.node] }\n : { id: newId(\"m\"), kind: \"submenu\", label: \"More\", icon: \"ChevronRight\", children: [at.node] };\n return insertNode(removeNode(nodes, id), at.parentId, at.index, wrapper);\n}\n\n/** Replace a container by its children. */\nexport function unwrapNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at || !isContainer(at.node)) return [...nodes];\n let out = removeNode(nodes, id);\n at.node.children.forEach((child, i) => {\n out = insertNode(out, at.parentId, at.index + i, child);\n });\n return out;\n}\n\n// ── reading a saved design defensively ──────────────────────────────────────\n\n/** A saved document back into a design; anything unreadable becomes an empty design, never a throw. */\nexport function parseDesign(raw: unknown): MenuDesign {\n let value: unknown = raw;\n if (typeof raw === \"string\") {\n try {\n value = JSON.parse(raw);\n } catch {\n return emptyDesign(\"Unreadable design\");\n }\n }\n const v = (value ?? {}) as Partial<MenuDesign> & { design?: MenuDesign };\n const d = v.design ?? v;\n return {\n designId: typeof d.designId === \"string\" ? d.designId : newId(\"d\"),\n name: typeof d.name === \"string\" ? d.name : \"Untitled menu\",\n surface: MENU_SURFACES.includes(d.surface as MenuSurface) ? (d.surface as MenuSurface) : \"anywhere\",\n look: { ...DEFAULT_LOOK, ...(d.look ?? {}) },\n nodes: Array.isArray(d.nodes) ? (d.nodes as MenuNode[]) : [],\n notes: typeof d.notes === \"string\" ? d.notes : \"\",\n };\n}\n\nexport function serializeDesign(design: MenuDesign): string {\n return JSON.stringify({ format: DESIGN_FORMAT, design });\n}\n"],"mappings":";;;AAQA,SAAS,aAAa,cAAc,cAAc,kBAAkB,SAAS;;;ACsEtE,IAAM,gBAAgB;AAAA,EAC3B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAaO,IAAM,eAAyB;AAAA,EACpC,SAAS;AAAA,EACT,OAAO;AAAA,EACP,WAAW;AAAA,EACX,eAAe;AAAA,EACf,WAAW;AAAA,EACX,QAAQ;AACV;AAaO,IAAM,gBAAgB;AAItB,SAAS,MAAM,SAAS,KAAa;AAC1C,QAAM,MACJ,OAAO,WAAW,eAAe,gBAAgB,SAC7C,OAAO,WAAW,EAAE,MAAM,GAAG,CAAC,IAC9B,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,EAAE;AAC5C,SAAO,GAAG,MAAM,IAAI,GAAG;AACzB;AAEO,SAAS,YAAY,OAAO,iBAA6B;AAC9D,SAAO,EAAE,UAAU,MAAM,GAAG,GAAG,MAAM,SAAS,YAAY,MAAM,EAAE,GAAG,aAAa,GAAG,OAAO,CAAC,GAAG,OAAO,GAAG;AAC5G;AAqJO,SAAS,YAAY,KAA0B;AACpD,MAAI,QAAiB;AACrB,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI;AACF,cAAQ,KAAK,MAAM,GAAG;AAAA,IACxB,QAAQ;AACN,aAAO,YAAY,mBAAmB;AAAA,IACxC;AAAA,EACF;AACA,QAAM,IAAK,SAAS,CAAC;AACrB,QAAM,IAAI,EAAE,UAAU;AACtB,SAAO;AAAA,IACL,UAAU,OAAO,EAAE,aAAa,WAAW,EAAE,WAAW,MAAM,GAAG;AAAA,IACjE,MAAM,OAAO,EAAE,SAAS,WAAW,EAAE,OAAO;AAAA,IAC5C,SAAS,cAAc,SAAS,EAAE,OAAsB,IAAK,EAAE,UAA0B;AAAA,IACzF,MAAM,EAAE,GAAG,cAAc,GAAI,EAAE,QAAQ,CAAC,EAAG;AAAA,IAC3C,OAAO,MAAM,QAAQ,EAAE,KAAK,IAAK,EAAE,QAAuB,CAAC;AAAA,IAC3D,OAAO,OAAO,EAAE,UAAU,WAAW,EAAE,QAAQ;AAAA,EACjD;AACF;AAEO,SAAS,gBAAgB,QAA4B;AAC1D,SAAO,KAAK,UAAU,EAAE,QAAQ,eAAe,OAAO,CAAC;AACzD;;;ADrSO,IAAM,kBAAkB,iBAAiB;AAAA,EAC9C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,UAAU;AAAA,EACV,OAAO;AAAA,EACP,OAAO;AAAA,EACP,KAAK;AAAA,EACL,aAAa;AAAA,EACb,QAAQ;AAAA,IACN,aAAa,EAAE,KAAK,EAAE,OAAO,eAAe,QAAQ,MAAM,UAAU,KAAK,CAAC;AAAA,IAC1E,WAAW,EAAE,KAAK,EAAE,OAAO,aAAa,UAAU,KAAK,CAAC;AAAA,IACxD,MAAM,EAAE,KAAK,EAAE,OAAO,QAAQ,UAAU,KAAK,CAAC;AAAA,IAC9C,SAAS,EAAE,QAAQ,EAAE,OAAO,WAAW,UAAU,KAAK,CAAC;AAAA,IACvD,KAAK,EAAE,SAAS,EAAE,OAAO,QAAQ,UAAU,KAAK,CAAC;AAAA,IACjD,MAAM,EAAE,KAAK,EAAE,OAAO,OAAO,CAAC;AAAA,IAC9B,UAAU,EAAE,KAAK,EAAE,OAAO,WAAW,CAAC;AAAA,IACtC,UAAU,EAAE,SAAS,EAAE,OAAO,QAAQ,CAAC;AAAA,EACzC;AACF,CAAC;AAyBD,IAAM,WAAW,CAAC,GAAuB,MACvC,EAAE,QAAQ,cAAc,EAAE,OAAO,KAAK,EAAE,UAAU,EAAE;AAEtD,SAAS,YAAY,KAA2B,UAA0B;AACxE,SAAO,IAAI,OAAO,CAAC,MAAM,EAAE,aAAa,QAAQ,EAAE,OAAO,CAAC,GAAG,MAAM,KAAK,IAAI,GAAG,EAAE,OAAO,GAAG,CAAC,IAAI;AAClG;AAEO,SAAS,mBAAmB,QAAuB,SAAoE;AAC5H,QAAM,OAAO,YAA2C;AACtD,UAAM,OAAO,MAAM,YAAY,QAAQ,eAAe;AACtD,QAAI,CAAC,KAAK,GAAI,OAAM,IAAI,MAAM,KAAK,MAAM,OAAO;AAChD,WAAO,KAAK,KAAK,KACd,IAAI,CAAC,OAAO;AAAA,MACX,OAAO,OAAO,EAAE,GAAG;AAAA,MACnB,UAAU,OAAO,EAAE,SAAS;AAAA,MAC5B,MAAM,OAAO,EAAE,IAAI;AAAA,MACnB,SAAS,OAAO,EAAE,OAAO;AAAA,MACzB,MAAM,OAAO,EAAE,QAAQ,EAAE;AAAA,MACzB,SAAS,OAAO,EAAE,YAAY,EAAE;AAAA,MAChC,SAAS,OAAO,EAAE,YAAY,EAAE;AAAA,MAChC,QAAQ,YAAY,EAAE,GAAG;AAAA,IAC3B,EAAE,EACD,KAAK,QAAQ;AAAA,EAClB;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN;AAAA,IACA,MAAM,KAAK,QAAQ,OAAO,IAAI;AAC5B,YAAM,MAAM,MAAM,KAAK;AACvB,YAAM,UAAU,YAAY,KAAK,OAAO,QAAQ;AAChD,YAAM,WAAU,oBAAI,KAAK,GAAE,YAAY;AACvC,YAAM,UAAU,QAAQ,WAAW;AACnC,YAAM,QAAQ,MAAM;AAAA,QAClB;AAAA,QACA;AAAA,QACA;AAAA,UACE,aAAa,GAAG,OAAO,QAAQ,IAAI,OAAO;AAAA,UAC1C,WAAW,OAAO;AAAA,UAClB,MAAM,OAAO;AAAA,UACb;AAAA,UACA,KAAK,gBAAgB,MAAM;AAAA,UAC3B;AAAA,UACA,UAAU;AAAA,UACV,UAAU;AAAA,QACZ;AAAA,QACA,EAAE,gBAAgB,QAAQ,eAAe;AAAA,MAC3C;AACA,UAAI,CAAC,MAAM,GAAI,OAAM,IAAI,MAAM,MAAM,MAAM,OAAO;AAClD,aAAO,EAAE,OAAO,MAAM,MAAM,UAAU,OAAO,UAAU,MAAM,OAAO,MAAM,SAAS,MAAM,SAAS,SAAS,OAAO;AAAA,IACpH;AAAA,IACA,MAAM,OAAO,OAAO;AAClB,YAAM,OAAO,MAAM,aAAa,QAAQ,iBAAiB,OAAO,EAAE,gBAAgB,QAAQ,eAAe,CAAC;AAC1G,UAAI,CAAC,KAAK,GAAI,OAAM,IAAI,MAAM,KAAK,MAAM,OAAO;AAAA,IAClD;AAAA,EACF;AACF;AAEA,IAAM,YAAY;AAEX,SAAS,mBAAgC;AAC9C,QAAM,OAAO,MAA4B;AACvC,QAAI;AACF,YAAM,MAAM,WAAW,cAAc,QAAQ,SAAS;AACtD,YAAM,OAAO,MAAO,KAAK,MAAM,GAAG,IAA6B,CAAC;AAChE,aAAO,KAAK,IAAI,CAAC,OAAO,EAAE,GAAG,GAAG,QAAQ,YAAY,EAAE,MAAM,EAAE,EAAE;AAAA,IAClE,QAAQ;AACN,aAAO,CAAC;AAAA,IACV;AAAA,EACF;AACA,QAAM,QAAQ,CAAC,SAA+B;AAC5C,QAAI;AACF,iBAAW,cAAc,QAAQ,WAAW,KAAK,UAAU,IAAI,CAAC;AAAA,IAClE,QAAQ;AAAA,IAER;AAAA,EACF;AACA,SAAO;AAAA,IACL,MAAM;AAAA,IACN,MAAM,OAAO;AACX,aAAO,KAAK,EAAE,KAAK,QAAQ;AAAA,IAC7B;AAAA,IACA,MAAM,KAAK,QAAQ,OAAO,IAAI;AAC5B,YAAM,MAAM,KAAK;AACjB,YAAM,UAAU,YAAY,KAAK,OAAO,QAAQ;AAChD,YAAM,QAA4B;AAAA,QAChC,OAAO,GAAG,OAAO,QAAQ,IAAI,OAAO;AAAA,QACpC,UAAU,OAAO;AAAA,QACjB,MAAM,OAAO;AAAA,QACb;AAAA,QACA;AAAA,QACA,UAAS,oBAAI,KAAK,GAAE,YAAY;AAAA,QAChC,SAAS;AAAA,QACT;AAAA,MACF;AACA,YAAM,CAAC,OAAO,GAAG,GAAG,CAAC;AACrB,aAAO;AAAA,IACT;AAAA,IACA,MAAM,OAAO,OAAO;AAClB,YAAM,KAAK,EAAE,OAAO,CAAC,MAAM,EAAE,UAAU,KAAK,CAAC;AAAA,IAC/C;AAAA,EACF;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/design/store.ts","../src/design/model.ts"],"sourcesContent":["// src/design/store.ts — @ai-matrx/context-menu/design-store: WHERE MENU DESIGNS ARE SAVED.\n//\n// Every Save writes a NEW VERSION of a design; nothing is overwritten, so any earlier version can be\n// opened again. Two stores answer the same interface:\n// - recordsDesignStore: a typed table in the record store (`custom.*`), so a saved design is real\n// shared data the builder's author, Claude, and the app can all read — no hand-made schema;\n// - localDesignStore: this browser's storage, for a playground with no sign-in; it says so.\n\nimport { listAppRows, removeAppRow, upsertAppRow, defineTypedTable, f } from \"@ai-matrx/records/typed-table\";\nimport type { RecordsClient } from \"@ai-matrx/records/core\";\nimport { parseDesign, serializeDesign, type MenuDesign } from \"./model\";\n\nexport const menuDesignTable = defineTypedTable({\n name: \"Menu designs\",\n slug: \"menu_designs\",\n kept_for: \"context_menu\",\n scope: \"organization\",\n owner: \"lane:CONTEXT-MENU\",\n key: \"version_key\",\n declared_in: \"aidream:apps/shared/context-menu/src/design/store.ts\",\n fields: {\n version_key: f.text({ label: \"Version key\", unique: true, required: true }),\n design_id: f.text({ label: \"Design id\", required: true }),\n name: f.text({ label: \"Name\", required: true }),\n version: f.integer({ label: \"Version\", required: true }),\n doc: f.longText({ label: \"Menu\", required: true }),\n note: f.text({ label: \"Note\" }),\n saved_by: f.text({ label: \"Saved by\" }),\n saved_at: f.datetime({ label: \"Saved\" }),\n },\n});\n\nexport interface SavedDesignVersion {\n /** The stored row (record id, or a local key). */\n rowId: string;\n designId: string;\n name: string;\n version: number;\n note: string;\n savedAt: string;\n savedBy: string;\n design: MenuDesign;\n}\n\nexport interface DesignStore {\n /** \"live\" saves to shared data; \"local\" saves to this browser only. */\n readonly kind: \"live\" | \"local\";\n /** Every saved version of every design, newest first. */\n list(): Promise<SavedDesignVersion[]>;\n /** Save `design` as its next version. Answers the saved version. */\n save(design: MenuDesign, note?: string): Promise<SavedDesignVersion>;\n /** Archive one version (restorable from the record store's archive). */\n remove(rowId: string): Promise<void>;\n}\n\nconst byNewest = (a: SavedDesignVersion, b: SavedDesignVersion) =>\n b.savedAt.localeCompare(a.savedAt) || b.version - a.version;\n\nfunction nextVersion(all: SavedDesignVersion[], designId: string): number {\n return all.filter((v) => v.designId === designId).reduce((m, v) => Math.max(m, v.version), 0) + 1;\n}\n\nexport function recordsDesignStore(client: RecordsClient, options: { organizationId: string; savedBy?: string }): DesignStore {\n const list = async (): Promise<SavedDesignVersion[]> => {\n const read = await listAppRows(client, menuDesignTable);\n if (!read.ok) throw new Error(read.error.message);\n return read.data.rows\n .map((r) => ({\n rowId: String(r._id),\n designId: String(r.design_id),\n name: String(r.name),\n version: Number(r.version),\n note: String(r.note ?? \"\"),\n savedAt: String(r.saved_at ?? \"\"),\n savedBy: String(r.saved_by ?? \"\"),\n design: parseDesign(r.doc),\n }))\n .sort(byNewest);\n };\n return {\n kind: \"live\",\n list,\n async save(design, note = \"\") {\n const all = await list();\n const version = nextVersion(all, design.designId);\n const savedAt = new Date().toISOString();\n const savedBy = options.savedBy ?? \"\";\n const wrote = await upsertAppRow(\n client,\n menuDesignTable,\n {\n version_key: `${design.designId}@${version}`,\n design_id: design.designId,\n name: design.name,\n version,\n doc: serializeDesign(design),\n note,\n saved_by: savedBy,\n saved_at: savedAt,\n },\n { organizationId: options.organizationId },\n );\n if (!wrote.ok) throw new Error(wrote.error.message);\n return { rowId: wrote.data, designId: design.designId, name: design.name, version, note, savedAt, savedBy, design };\n },\n async remove(rowId) {\n const gone = await removeAppRow(client, menuDesignTable, rowId, { organizationId: options.organizationId });\n if (!gone.ok) throw new Error(gone.error.message);\n },\n };\n}\n\nconst LOCAL_KEY = \"matrx.context-menu.designs.v1\";\n\nexport function localDesignStore(): DesignStore {\n const read = (): SavedDesignVersion[] => {\n try {\n const raw = globalThis.localStorage?.getItem(LOCAL_KEY);\n const rows = raw ? (JSON.parse(raw) as SavedDesignVersion[]) : [];\n return rows.map((r) => ({ ...r, design: parseDesign(r.design) }));\n } catch {\n return [];\n }\n };\n const write = (rows: SavedDesignVersion[]) => {\n try {\n globalThis.localStorage?.setItem(LOCAL_KEY, JSON.stringify(rows));\n } catch {\n /* storage blocked: the save still answers; the next reload will not have it */\n }\n };\n return {\n kind: \"local\",\n async list() {\n return read().sort(byNewest);\n },\n async save(design, note = \"\") {\n const all = read();\n const version = nextVersion(all, design.designId);\n const saved: SavedDesignVersion = {\n rowId: `${design.designId}@${version}`,\n designId: design.designId,\n name: design.name,\n version,\n note,\n savedAt: new Date().toISOString(),\n savedBy: \"this browser\",\n design,\n };\n write([saved, ...all]);\n return saved;\n },\n async remove(rowId) {\n write(read().filter((r) => r.rowId !== rowId));\n },\n };\n}\n","// src/design/model.ts — @ai-matrx/context-menu: THE MENU DESIGN.\n//\n// A design is the whole menu a person drew in the builder: an ordered tree of items, sections,\n// submenus and dividers, plus the few presentation choices that change how it reads. It is plain\n// JSON (no functions, no React) so it saves as one record, travels in a URL or a chat, and is the\n// exact thing the real menu is later built from. Every edit is a pure function returning a new\n// design; nothing here mutates.\n\nexport type MenuNodeKind = \"item\" | \"section\" | \"submenu\" | \"divider\" | \"slot\" | \"search\";\n\n/**\n * A row's colour, from the design system's tokens: the role inks (primary … destructive) and the\n * named hues (the chip palette). Light and dark come with the tokens.\n */\nexport const MENU_TONES = [\n \"default\",\n \"primary\",\n \"success\",\n \"warning\",\n \"info\",\n \"destructive\",\n \"sky\",\n \"blue\",\n \"indigo\",\n \"violet\",\n \"fuchsia\",\n \"rose\",\n \"orange\",\n \"amber\",\n \"lime\",\n \"emerald\",\n \"teal\",\n \"cyan\",\n \"slate\",\n] as const;\nexport type MenuTone = (typeof MENU_TONES)[number];\n\n/**\n * What the live menu does with an entry it cannot offer right now (no editable text, not an admin…):\n * - disable: shown greyed out;\n * - hide: left out;\n * - required: never unavailable — the host must always provide it.\n */\nexport const UNAVAILABLE_MODES = [\"disable\", \"hide\", \"required\"] as const;\nexport type UnavailableMode = (typeof UNAVAILABLE_MODES)[number];\n\ninterface NodeBase {\n /** Stable within the design. */\n id: string;\n /** The catalog option this was dragged from, when it came from one. */\n catalogId?: string;\n /** A designer's note on this one entry (\"only on a table row\", \"merge with Copy\"). */\n note?: string;\n /** Extra words the menu's search matches (besides the label and hint). */\n keywords?: string[];\n /** What happens when the entry is unavailable. Absent = disable. */\n unavailable?: UnavailableMode;\n /** When it is unavailable, in words (\"no editable text\", \"not an org admin\"). */\n unavailableWhen?: string;\n}\n\nexport interface MenuItemNode extends NodeBase {\n kind: \"item\";\n label: string;\n /** A lucide icon name (`Sparkles`, `Copy`). */\n icon?: string;\n /** Shown right-aligned (`⌘C`). */\n shortcut?: string;\n /** One short line under the label. */\n hint?: string;\n /** Shown greyed out — the design says \"this exists here but is unavailable now\". */\n disabled?: boolean;\n /** Red, for actions that remove or discard. */\n danger?: boolean;\n /** A checkbox-style toggle item. */\n toggle?: boolean;\n /** A setting the person flips right from the menu, drawn as a switch. */\n switch?: boolean;\n /** A checkbox or switch starts on. */\n defaultOn?: boolean;\n /** Drawn as a highlighted, AI-flavoured item. */\n ai?: boolean;\n /** The row's colour (icon; the label too with `toneLabel`). */\n tone?: MenuTone;\n toneLabel?: boolean;\n}\n\nexport interface MenuSectionNode extends NodeBase {\n kind: \"section\";\n /** The small heading. Empty = an unlabelled group. */\n label: string;\n children: MenuNode[];\n /** Starts folded; a click on the heading opens it. */\n collapsible?: boolean;\n /** How the section's items are laid out. */\n layout?: \"list\" | \"grid\" | \"row\";\n}\n\nexport interface MenuSubmenuNode extends NodeBase {\n kind: \"submenu\";\n label: string;\n icon?: string;\n hint?: string;\n tone?: MenuTone;\n toneLabel?: boolean;\n children: MenuNode[];\n}\n\n/** THE MENU'S SEARCH BOX: filters every option of the menu (labels, hints, keywords) as you type. */\nexport interface MenuSearchNode extends NodeBase {\n kind: \"search\";\n placeholder: string;\n /** Takes the keyboard when the menu opens. */\n autofocus: boolean;\n}\n\nexport interface MenuDividerNode extends NodeBase {\n kind: \"divider\";\n}\n\n/**\n * A PLACEHOLDER: \"every other option of this kind goes here\". `shape: \"items\"` lays them out as rows\n * where the placeholder sits; `shape: \"submenu\"` gathers them into one submenu. `fills` names the\n * kind (a catalog group, the agent library, the page's own rows, or everything not placed).\n */\nexport interface MenuSlotNode extends NodeBase {\n kind: \"slot\";\n label: string;\n fills: string;\n /**\n * items: rows where it sits · submenu: one submenu · feature: THE FEATURE SECTION — a section header\n * (the label), the first `featureItems` options as rows, the rest in a nested \"More\" submenu.\n */\n shape: \"items\" | \"submenu\" | \"feature\";\n icon?: string;\n /** feature shape: how many options show before \"More\". Default 4. */\n featureItems?: number;\n}\n\nexport type MenuNode = MenuItemNode | MenuSectionNode | MenuSubmenuNode | MenuDividerNode | MenuSlotNode | MenuSearchNode;\nexport type MenuContainerNode = MenuSectionNode | MenuSubmenuNode;\n\n/** Where the menu opens; the builder previews each the way it appears there. */\nexport const MENU_SURFACES = [\n \"text-selection\",\n \"editable-field\",\n \"table-row\",\n \"page\",\n \"chat-message\",\n \"file\",\n \"anywhere\",\n] as const;\nexport type MenuSurface = (typeof MENU_SURFACES)[number];\n\nexport interface MenuLook {\n density: \"compact\" | \"comfortable\";\n width: number;\n showIcons: boolean;\n showShortcuts: boolean;\n showHints: boolean;\n /** A search box at the top of the menu. */\n search: boolean;\n}\n\nexport const DEFAULT_LOOK: MenuLook = {\n density: \"comfortable\",\n width: 280,\n showIcons: true,\n showShortcuts: true,\n showHints: false,\n search: false,\n};\n\nexport interface MenuDesign {\n /** The design's identity; every version shares it. */\n designId: string;\n name: string;\n surface: MenuSurface;\n look: MenuLook;\n nodes: MenuNode[];\n /** Free notes for whoever builds it. */\n notes: string;\n}\n\nexport const DESIGN_FORMAT = 1;\n\n// ── ids ──────────────────────────────────────────────────────────────────────\n\nexport function newId(prefix = \"n\"): string {\n const rnd =\n typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n ? crypto.randomUUID().slice(0, 8)\n : Math.random().toString(36).slice(2, 10);\n return `${prefix}_${rnd}`;\n}\n\nexport function emptyDesign(name = \"Untitled menu\"): MenuDesign {\n return { designId: newId(\"d\"), name, surface: \"anywhere\", look: { ...DEFAULT_LOOK }, nodes: [], notes: \"\" };\n}\n\nexport function isContainer(node: MenuNode): node is MenuContainerNode {\n return node.kind === \"section\" || node.kind === \"submenu\";\n}\n\n/** Every catalog option the design already places (so a placeholder holds only the rest). */\nexport function placedCatalogIds(nodes: readonly MenuNode[], out = new Set<string>()): Set<string> {\n for (const n of nodes) {\n if (n.catalogId) out.add(n.catalogId);\n if (isContainer(n)) placedCatalogIds(n.children, out);\n }\n return out;\n}\n\n/** How many times each catalog option appears in the design (a duplicate counts twice). */\nexport function catalogUseCounts(nodes: readonly MenuNode[], out = new Map<string, number>()): Map<string, number> {\n for (const n of nodes) {\n if (n.catalogId) out.set(n.catalogId, (out.get(n.catalogId) ?? 0) + 1);\n if (isContainer(n)) catalogUseCounts(n.children, out);\n }\n return out;\n}\n\n/** A deep copy with every id renewed — what a duplicate or a second drop of a template gets. */\nexport function cloneNode<N extends MenuNode>(node: N): N {\n const copy = { ...node, id: newId(node.kind[0]) } as N;\n if (isContainer(copy)) (copy as MenuContainerNode).children = (copy as MenuContainerNode).children.map(cloneNode);\n return copy;\n}\n\n// ── reading the tree ─────────────────────────────────────────────────────────\n\nexport interface Located {\n node: MenuNode;\n /** The container's id, or null at the top. */\n parentId: string | null;\n index: number;\n depth: number;\n}\n\nexport function locate(nodes: readonly MenuNode[], id: string, parentId: string | null = null, depth = 0): Located | null {\n for (let i = 0; i < nodes.length; i++) {\n const node = nodes[i] as MenuNode;\n if (node.id === id) return { node, parentId, index: i, depth };\n if (isContainer(node)) {\n const inner = locate(node.children, id, node.id, depth + 1);\n if (inner) return inner;\n }\n }\n return null;\n}\n\nexport function contains(node: MenuNode, id: string): boolean {\n if (node.id === id) return true;\n return isContainer(node) && node.children.some((c) => contains(c, id));\n}\n\nexport function countItems(nodes: readonly MenuNode[]): number {\n let n = 0;\n for (const node of nodes) {\n if (node.kind === \"item\" || node.kind === \"slot\") n += 1;\n else if (isContainer(node)) n += countItems(node.children) + (node.kind === \"submenu\" ? 1 : 0);\n }\n return n;\n}\n\n// ── editing the tree (pure) ──────────────────────────────────────────────────\n\nfunction mapChildren(nodes: readonly MenuNode[], parentId: string | null, fn: (list: MenuNode[]) => MenuNode[]): MenuNode[] {\n if (parentId === null) return fn([...nodes]);\n return nodes.map((node) => {\n if (!isContainer(node)) return node;\n if (node.id === parentId) return { ...node, children: fn([...node.children]) };\n return { ...node, children: mapChildren(node.children, parentId, fn) };\n });\n}\n\nexport function insertNode(nodes: readonly MenuNode[], parentId: string | null, index: number, node: MenuNode): MenuNode[] {\n return mapChildren(nodes, parentId, (list) => {\n list.splice(Math.max(0, Math.min(index, list.length)), 0, node);\n return list;\n });\n}\n\nexport function removeNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n return nodes\n .filter((n) => n.id !== id)\n .map((n) => (isContainer(n) ? { ...n, children: removeNode(n.children, id) } : n));\n}\n\nexport function updateNode(nodes: readonly MenuNode[], id: string, patch: Partial<MenuNode>): MenuNode[] {\n return nodes.map((n) => {\n if (n.id === id) return { ...n, ...patch, id: n.id, kind: n.kind } as MenuNode;\n if (isContainer(n)) return { ...n, children: updateNode(n.children, id, patch) };\n return n;\n });\n}\n\n/**\n * Move `id` to `index` inside `parentId`. Refuses (returns the tree unchanged) a move into itself\n * or its own descendant. `index` is in the destination list as it reads BEFORE the move.\n */\nexport function canMove(nodes: readonly MenuNode[], id: string, parentId: string | null): boolean {\n const from = locate(nodes, id);\n return Boolean(from) && (parentId === null || !contains((from as Located).node, parentId));\n}\n\nexport function moveNode(nodes: readonly MenuNode[], id: string, parentId: string | null, index: number): MenuNode[] {\n const from = locate(nodes, id);\n // A refused move answers the SAME array, so a caller can tell \"nothing happened\" by identity.\n if (!from) return nodes as MenuNode[];\n if (parentId !== null && contains(from.node, parentId)) return nodes as MenuNode[];\n let target = index;\n if (from.parentId === parentId && from.index < index) target -= 1;\n return insertNode(removeNode(nodes, id), parentId, target, from.node);\n}\n\nexport function duplicateNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n return insertNode(nodes, at.parentId, at.index + 1, cloneNode(at.node));\n}\n\n/** Wrap a node into a new section (or submenu) in its place. */\nexport function wrapNode(nodes: readonly MenuNode[], id: string, kind: \"section\" | \"submenu\"): MenuNode[] {\n const at = locate(nodes, id);\n if (!at) return [...nodes];\n const wrapper: MenuContainerNode =\n kind === \"section\"\n ? { id: newId(\"s\"), kind: \"section\", label: \"New section\", children: [at.node] }\n : { id: newId(\"m\"), kind: \"submenu\", label: \"More\", icon: \"ChevronRight\", children: [at.node] };\n return insertNode(removeNode(nodes, id), at.parentId, at.index, wrapper);\n}\n\n/** Replace a container by its children. */\nexport function unwrapNode(nodes: readonly MenuNode[], id: string): MenuNode[] {\n const at = locate(nodes, id);\n if (!at || !isContainer(at.node)) return [...nodes];\n let out = removeNode(nodes, id);\n at.node.children.forEach((child, i) => {\n out = insertNode(out, at.parentId, at.index + i, child);\n });\n return out;\n}\n\n// ── reading a saved design defensively ──────────────────────────────────────\n\n/** A saved document back into a design; anything unreadable becomes an empty design, never a throw. */\nexport function parseDesign(raw: unknown): MenuDesign {\n let value: unknown = raw;\n if (typeof raw === \"string\") {\n try {\n value = JSON.parse(raw);\n } catch {\n return emptyDesign(\"Unreadable design\");\n }\n }\n const v = (value ?? {}) as Partial<MenuDesign> & { design?: MenuDesign };\n const d = v.design ?? v;\n return {\n designId: typeof d.designId === \"string\" ? d.designId : newId(\"d\"),\n name: typeof d.name === \"string\" ? d.name : \"Untitled menu\",\n surface: MENU_SURFACES.includes(d.surface as MenuSurface) ? (d.surface as MenuSurface) : \"anywhere\",\n look: { ...DEFAULT_LOOK, ...(d.look ?? {}) },\n nodes: Array.isArray(d.nodes) ? (d.nodes as MenuNode[]) : [],\n notes: typeof d.notes === \"string\" ? d.notes : \"\",\n };\n}\n\nexport function serializeDesign(design: MenuDesign): string {\n return JSON.stringify({ format: DESIGN_FORMAT, design });\n}\n"],"mappings":"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","names":[]}
|