@ai-matrx/context-menu 0.0.0 → 0.1.0

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +14 -1
  3. package/dist/builder.cjs +1665 -0
  4. package/dist/builder.cjs.map +1 -0
  5. package/dist/builder.d.cts +22 -0
  6. package/dist/builder.d.ts +22 -0
  7. package/dist/builder.js +1650 -0
  8. package/dist/builder.js.map +1 -0
  9. package/dist/design-store.cjs +195 -0
  10. package/dist/design-store.cjs.map +1 -0
  11. package/dist/design-store.d.cts +42 -0
  12. package/dist/design-store.d.ts +42 -0
  13. package/dist/design-store.js +175 -0
  14. package/dist/design-store.js.map +1 -0
  15. package/dist/engine.cjs +3069 -0
  16. package/dist/engine.cjs.map +1 -0
  17. package/dist/engine.d.cts +731 -0
  18. package/dist/engine.d.ts +731 -0
  19. package/dist/engine.js +3250 -0
  20. package/dist/engine.js.map +1 -0
  21. package/dist/grouping-CB3zlD1T.d.ts +623 -0
  22. package/dist/grouping-CGcWsltu.d.cts +623 -0
  23. package/dist/host-types-AOiEYwR8.d.cts +9 -0
  24. package/dist/host-types-AOiEYwR8.d.ts +9 -0
  25. package/dist/index.cjs +486 -0
  26. package/dist/index.cjs.map +1 -0
  27. package/dist/index.d.cts +46 -0
  28. package/dist/index.d.ts +46 -0
  29. package/dist/index.js +464 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/menu-content.cjs +3146 -0
  32. package/dist/menu-content.cjs.map +1 -0
  33. package/dist/menu-content.d.cts +30 -0
  34. package/dist/menu-content.d.ts +30 -0
  35. package/dist/menu-content.js +3197 -0
  36. package/dist/menu-content.js.map +1 -0
  37. package/dist/model-BdF0TqZ3.d.cts +107 -0
  38. package/dist/model-BdF0TqZ3.d.ts +107 -0
  39. package/dist/react.cjs +3322 -0
  40. package/dist/react.cjs.map +1 -0
  41. package/dist/react.d.cts +133 -0
  42. package/dist/react.d.ts +133 -0
  43. package/dist/react.js +3345 -0
  44. package/dist/react.js.map +1 -0
  45. package/package.json +170 -1
@@ -0,0 +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\";\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\nexport type MenuNode = MenuItemNode | MenuSectionNode | MenuSubmenuNode | MenuDividerNode;\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/** 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\") 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":[]}
@@ -0,0 +1,42 @@
1
+ import * as _ai_matrx_records_typed_table from '@ai-matrx/records/typed-table';
2
+ import { RecordsClient } from '@ai-matrx/records/core';
3
+ import { M as MenuDesign } from './model-BdF0TqZ3.cjs';
4
+
5
+ declare const menuDesignTable: _ai_matrx_records_typed_table.TypedTableDef<{
6
+ version_key: _ai_matrx_records_typed_table.AppField<string, true, false, true>;
7
+ design_id: _ai_matrx_records_typed_table.AppField<string, true, false, false>;
8
+ name: _ai_matrx_records_typed_table.AppField<string, true, false, false>;
9
+ version: _ai_matrx_records_typed_table.AppField<number, true, false, false>;
10
+ doc: _ai_matrx_records_typed_table.AppField<string, true, false, false>;
11
+ note: _ai_matrx_records_typed_table.AppField<string, false, false, false>;
12
+ saved_by: _ai_matrx_records_typed_table.AppField<string, false, false, false>;
13
+ saved_at: _ai_matrx_records_typed_table.AppField<string, false, false, false>;
14
+ }, "version_key">;
15
+ interface SavedDesignVersion {
16
+ /** The stored row (record id, or a local key). */
17
+ rowId: string;
18
+ designId: string;
19
+ name: string;
20
+ version: number;
21
+ note: string;
22
+ savedAt: string;
23
+ savedBy: string;
24
+ design: MenuDesign;
25
+ }
26
+ interface DesignStore {
27
+ /** "live" saves to shared data; "local" saves to this browser only. */
28
+ readonly kind: "live" | "local";
29
+ /** Every saved version of every design, newest first. */
30
+ list(): Promise<SavedDesignVersion[]>;
31
+ /** Save `design` as its next version. Answers the saved version. */
32
+ save(design: MenuDesign, note?: string): Promise<SavedDesignVersion>;
33
+ /** Archive one version (restorable from the record store's archive). */
34
+ remove(rowId: string): Promise<void>;
35
+ }
36
+ declare function recordsDesignStore(client: RecordsClient, options: {
37
+ organizationId: string;
38
+ savedBy?: string;
39
+ }): DesignStore;
40
+ declare function localDesignStore(): DesignStore;
41
+
42
+ export { type DesignStore, type SavedDesignVersion, localDesignStore, menuDesignTable, recordsDesignStore };
@@ -0,0 +1,42 @@
1
+ import * as _ai_matrx_records_typed_table from '@ai-matrx/records/typed-table';
2
+ import { RecordsClient } from '@ai-matrx/records/core';
3
+ import { M as MenuDesign } from './model-BdF0TqZ3.js';
4
+
5
+ declare const menuDesignTable: _ai_matrx_records_typed_table.TypedTableDef<{
6
+ version_key: _ai_matrx_records_typed_table.AppField<string, true, false, true>;
7
+ design_id: _ai_matrx_records_typed_table.AppField<string, true, false, false>;
8
+ name: _ai_matrx_records_typed_table.AppField<string, true, false, false>;
9
+ version: _ai_matrx_records_typed_table.AppField<number, true, false, false>;
10
+ doc: _ai_matrx_records_typed_table.AppField<string, true, false, false>;
11
+ note: _ai_matrx_records_typed_table.AppField<string, false, false, false>;
12
+ saved_by: _ai_matrx_records_typed_table.AppField<string, false, false, false>;
13
+ saved_at: _ai_matrx_records_typed_table.AppField<string, false, false, false>;
14
+ }, "version_key">;
15
+ interface SavedDesignVersion {
16
+ /** The stored row (record id, or a local key). */
17
+ rowId: string;
18
+ designId: string;
19
+ name: string;
20
+ version: number;
21
+ note: string;
22
+ savedAt: string;
23
+ savedBy: string;
24
+ design: MenuDesign;
25
+ }
26
+ interface DesignStore {
27
+ /** "live" saves to shared data; "local" saves to this browser only. */
28
+ readonly kind: "live" | "local";
29
+ /** Every saved version of every design, newest first. */
30
+ list(): Promise<SavedDesignVersion[]>;
31
+ /** Save `design` as its next version. Answers the saved version. */
32
+ save(design: MenuDesign, note?: string): Promise<SavedDesignVersion>;
33
+ /** Archive one version (restorable from the record store's archive). */
34
+ remove(rowId: string): Promise<void>;
35
+ }
36
+ declare function recordsDesignStore(client: RecordsClient, options: {
37
+ organizationId: string;
38
+ savedBy?: string;
39
+ }): DesignStore;
40
+ declare function localDesignStore(): DesignStore;
41
+
42
+ export { type DesignStore, type SavedDesignVersion, localDesignStore, menuDesignTable, recordsDesignStore };
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ // src/design/store.ts
4
+ import { listAppRows, removeAppRow, upsertAppRow, defineTypedTable, f } from "@ai-matrx/records/typed-table";
5
+
6
+ // src/design/model.ts
7
+ var MENU_SURFACES = [
8
+ "text-selection",
9
+ "editable-field",
10
+ "table-row",
11
+ "page",
12
+ "chat-message",
13
+ "file",
14
+ "anywhere"
15
+ ];
16
+ var DEFAULT_LOOK = {
17
+ density: "comfortable",
18
+ width: 280,
19
+ showIcons: true,
20
+ showShortcuts: true,
21
+ showHints: false,
22
+ search: false
23
+ };
24
+ var DESIGN_FORMAT = 1;
25
+ function newId(prefix = "n") {
26
+ const rnd = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID().slice(0, 8) : Math.random().toString(36).slice(2, 10);
27
+ return `${prefix}_${rnd}`;
28
+ }
29
+ function emptyDesign(name = "Untitled menu") {
30
+ return { designId: newId("d"), name, surface: "anywhere", look: { ...DEFAULT_LOOK }, nodes: [], notes: "" };
31
+ }
32
+ function parseDesign(raw) {
33
+ let value = raw;
34
+ if (typeof raw === "string") {
35
+ try {
36
+ value = JSON.parse(raw);
37
+ } catch {
38
+ return emptyDesign("Unreadable design");
39
+ }
40
+ }
41
+ const v = value ?? {};
42
+ const d = v.design ?? v;
43
+ return {
44
+ designId: typeof d.designId === "string" ? d.designId : newId("d"),
45
+ name: typeof d.name === "string" ? d.name : "Untitled menu",
46
+ surface: MENU_SURFACES.includes(d.surface) ? d.surface : "anywhere",
47
+ look: { ...DEFAULT_LOOK, ...d.look ?? {} },
48
+ nodes: Array.isArray(d.nodes) ? d.nodes : [],
49
+ notes: typeof d.notes === "string" ? d.notes : ""
50
+ };
51
+ }
52
+ function serializeDesign(design) {
53
+ return JSON.stringify({ format: DESIGN_FORMAT, design });
54
+ }
55
+
56
+ // src/design/store.ts
57
+ var menuDesignTable = defineTypedTable({
58
+ name: "Menu designs",
59
+ slug: "menu_designs",
60
+ kept_for: "context_menu",
61
+ scope: "organization",
62
+ owner: "lane:CONTEXT-MENU",
63
+ key: "version_key",
64
+ declared_in: "aidream:apps/shared/context-menu/src/design/store.ts",
65
+ fields: {
66
+ version_key: f.text({ label: "Version key", unique: true, required: true }),
67
+ design_id: f.text({ label: "Design id", required: true }),
68
+ name: f.text({ label: "Name", required: true }),
69
+ version: f.integer({ label: "Version", required: true }),
70
+ doc: f.longText({ label: "Menu", required: true }),
71
+ note: f.text({ label: "Note" }),
72
+ saved_by: f.text({ label: "Saved by" }),
73
+ saved_at: f.datetime({ label: "Saved" })
74
+ }
75
+ });
76
+ var byNewest = (a, b) => b.savedAt.localeCompare(a.savedAt) || b.version - a.version;
77
+ function nextVersion(all, designId) {
78
+ return all.filter((v) => v.designId === designId).reduce((m, v) => Math.max(m, v.version), 0) + 1;
79
+ }
80
+ function recordsDesignStore(client, options) {
81
+ const list = async () => {
82
+ const read = await listAppRows(client, menuDesignTable);
83
+ if (!read.ok) throw new Error(read.error.message);
84
+ return read.data.rows.map((r) => ({
85
+ rowId: String(r._id),
86
+ designId: String(r.design_id),
87
+ name: String(r.name),
88
+ version: Number(r.version),
89
+ note: String(r.note ?? ""),
90
+ savedAt: String(r.saved_at ?? ""),
91
+ savedBy: String(r.saved_by ?? ""),
92
+ design: parseDesign(r.doc)
93
+ })).sort(byNewest);
94
+ };
95
+ return {
96
+ kind: "live",
97
+ list,
98
+ async save(design, note = "") {
99
+ const all = await list();
100
+ const version = nextVersion(all, design.designId);
101
+ const savedAt = (/* @__PURE__ */ new Date()).toISOString();
102
+ const savedBy = options.savedBy ?? "";
103
+ const wrote = await upsertAppRow(
104
+ client,
105
+ menuDesignTable,
106
+ {
107
+ version_key: `${design.designId}@${version}`,
108
+ design_id: design.designId,
109
+ name: design.name,
110
+ version,
111
+ doc: serializeDesign(design),
112
+ note,
113
+ saved_by: savedBy,
114
+ saved_at: savedAt
115
+ },
116
+ { organizationId: options.organizationId }
117
+ );
118
+ if (!wrote.ok) throw new Error(wrote.error.message);
119
+ return { rowId: wrote.data, designId: design.designId, name: design.name, version, note, savedAt, savedBy, design };
120
+ },
121
+ async remove(rowId) {
122
+ const gone = await removeAppRow(client, menuDesignTable, rowId, { organizationId: options.organizationId });
123
+ if (!gone.ok) throw new Error(gone.error.message);
124
+ }
125
+ };
126
+ }
127
+ var LOCAL_KEY = "matrx.context-menu.designs.v1";
128
+ function localDesignStore() {
129
+ const read = () => {
130
+ try {
131
+ const raw = globalThis.localStorage?.getItem(LOCAL_KEY);
132
+ const rows = raw ? JSON.parse(raw) : [];
133
+ return rows.map((r) => ({ ...r, design: parseDesign(r.design) }));
134
+ } catch {
135
+ return [];
136
+ }
137
+ };
138
+ const write = (rows) => {
139
+ try {
140
+ globalThis.localStorage?.setItem(LOCAL_KEY, JSON.stringify(rows));
141
+ } catch {
142
+ }
143
+ };
144
+ return {
145
+ kind: "local",
146
+ async list() {
147
+ return read().sort(byNewest);
148
+ },
149
+ async save(design, note = "") {
150
+ const all = read();
151
+ const version = nextVersion(all, design.designId);
152
+ const saved = {
153
+ rowId: `${design.designId}@${version}`,
154
+ designId: design.designId,
155
+ name: design.name,
156
+ version,
157
+ note,
158
+ savedAt: (/* @__PURE__ */ new Date()).toISOString(),
159
+ savedBy: "this browser",
160
+ design
161
+ };
162
+ write([saved, ...all]);
163
+ return saved;
164
+ },
165
+ async remove(rowId) {
166
+ write(read().filter((r) => r.rowId !== rowId));
167
+ }
168
+ };
169
+ }
170
+ export {
171
+ localDesignStore,
172
+ menuDesignTable,
173
+ recordsDesignStore
174
+ };
175
+ //# sourceMappingURL=design-store.js.map
@@ -0,0 +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\";\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\nexport type MenuNode = MenuItemNode | MenuSectionNode | MenuSubmenuNode | MenuDividerNode;\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/** 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\") 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":[]}