@tonycodes-ui/mortar 0.0.0-stage → 0.12.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 (107) hide show
  1. package/AGENTS.md +10 -0
  2. package/CHANGELOG.md +268 -0
  3. package/CONTRIBUTING.md +60 -0
  4. package/DESIGN.md +51 -0
  5. package/LICENSE +21 -0
  6. package/README.md +155 -2
  7. package/TESTING.md +83 -0
  8. package/baseline-policy.json +16 -0
  9. package/bin/mortar.mjs +258 -0
  10. package/catalog.json +9900 -0
  11. package/cli/audit.mjs +943 -0
  12. package/cli/data.mjs +176 -0
  13. package/cli/mcp.mjs +216 -0
  14. package/cli/migrations.mjs +128 -0
  15. package/cli/packet.mjs +267 -0
  16. package/cli/search.mjs +246 -0
  17. package/cli/tools.mjs +229 -0
  18. package/design-principles.json +30 -0
  19. package/dist/agent-data.json +1 -0
  20. package/dist/application.d.ts +340 -0
  21. package/dist/application.js +240 -0
  22. package/dist/auth.d.ts +121 -0
  23. package/dist/auth.js +97 -0
  24. package/dist/brand.d.ts +69 -0
  25. package/dist/brand.js +266 -0
  26. package/dist/calendar.d.ts +75 -0
  27. package/dist/calendar.js +310 -0
  28. package/dist/choice.d.ts +36 -0
  29. package/dist/choice.js +36 -0
  30. package/dist/composer.d.ts +28 -0
  31. package/dist/composer.js +47 -0
  32. package/dist/display.d.ts +136 -0
  33. package/dist/display.js +75 -0
  34. package/dist/docs.d.ts +145 -0
  35. package/dist/docs.js +141 -0
  36. package/dist/ecommerce.d.ts +261 -0
  37. package/dist/ecommerce.js +177 -0
  38. package/dist/feedback.d.ts +80 -0
  39. package/dist/feedback.js +98 -0
  40. package/dist/foundation.d.ts +42 -0
  41. package/dist/foundation.js +121 -0
  42. package/dist/heading.d.ts +5 -0
  43. package/dist/heading.js +9 -0
  44. package/dist/icons.d.ts +50 -0
  45. package/dist/icons.js +70 -0
  46. package/dist/illustration.d.ts +47 -0
  47. package/dist/illustration.js +47 -0
  48. package/dist/index.d.ts +36 -0
  49. package/dist/index.js +18 -0
  50. package/dist/inspection.d.ts +24 -0
  51. package/dist/inspection.js +14 -0
  52. package/dist/internal.d.ts +514 -0
  53. package/dist/internal.js +332 -0
  54. package/dist/layout.d.ts +107 -0
  55. package/dist/layout.js +66 -0
  56. package/dist/marketing-sections.d.ts +161 -0
  57. package/dist/marketing-sections.js +58 -0
  58. package/dist/marketing.d.ts +277 -0
  59. package/dist/marketing.js +163 -0
  60. package/dist/navigation.d.ts +35 -0
  61. package/dist/navigation.js +44 -0
  62. package/dist/primitives.d.ts +258 -0
  63. package/dist/primitives.js +244 -0
  64. package/dist/showcase.d.ts +29 -0
  65. package/dist/showcase.js +10 -0
  66. package/dist/workspace.d.ts +80 -0
  67. package/dist/workspace.js +48 -0
  68. package/docs/agents.md +127 -0
  69. package/docs/auth-flows.md +71 -0
  70. package/docs/migrating-to-0.6.md +94 -0
  71. package/docs/theming.md +170 -0
  72. package/package.json +94 -4
  73. package/skills/mortar/SKILL.md +36 -0
  74. package/src/application.css +1487 -0
  75. package/src/application.tsx +1413 -0
  76. package/src/auth.tsx +452 -0
  77. package/src/brand.ts +335 -0
  78. package/src/calendar.css +468 -0
  79. package/src/calendar.tsx +868 -0
  80. package/src/choice.tsx +240 -0
  81. package/src/composer.tsx +158 -0
  82. package/src/data.css +690 -0
  83. package/src/display.tsx +452 -0
  84. package/src/docs.css +704 -0
  85. package/src/docs.tsx +737 -0
  86. package/src/ecommerce.css +810 -0
  87. package/src/ecommerce.tsx +1033 -0
  88. package/src/feedback.tsx +388 -0
  89. package/src/foundation.css +1867 -0
  90. package/src/foundation.tsx +227 -0
  91. package/src/heading.ts +13 -0
  92. package/src/icons.tsx +237 -0
  93. package/src/illustration.tsx +169 -0
  94. package/src/index.ts +233 -0
  95. package/src/inspection.tsx +91 -0
  96. package/src/internal.tsx +700 -0
  97. package/src/layout.tsx +342 -0
  98. package/src/marketing-sections.tsx +621 -0
  99. package/src/marketing.css +2579 -0
  100. package/src/marketing.tsx +1235 -0
  101. package/src/navigation.tsx +118 -0
  102. package/src/overlays.css +350 -0
  103. package/src/primitives.tsx +1135 -0
  104. package/src/showcase.tsx +120 -0
  105. package/src/tokens.css +449 -0
  106. package/src/workspace.tsx +333 -0
  107. package/styles.css +56 -0
package/cli/packet.mjs ADDED
@@ -0,0 +1,267 @@
1
+ // Component contracts and build packets: everything an agent needs to compose
2
+ // with a set of components, from the installed catalogue and docs.
3
+ import { searchComponents, searchTemplates } from "./search.mjs";
4
+
5
+ export const CSS_SETUP = `@import "tailwindcss";
6
+ @import "@tonycodes-ui/mortar/styles.css";`;
7
+
8
+ export const THEME_SETUP = `import { ThemeProvider } from "@tonycodes-ui/mortar";
9
+
10
+ <ThemeProvider brand="neutral" density="comfortable" initialAppearance="system">
11
+ <App />
12
+ </ThemeProvider>`;
13
+
14
+ const importLine = (data, names) =>
15
+ `import { ${[...new Set(names)].sort().join(", ")} } from "${data.packageName}";`;
16
+
17
+ /** The full contract of one component, as get_component returns it. */
18
+ export function componentDetail(data, c, { example = true } = {}) {
19
+ const p = data.placement(c);
20
+ const props = Object.entries(c.contract.propTypes ?? {}).map(([name, t]) => ({
21
+ name,
22
+ type: t.type,
23
+ required: t.required,
24
+ ...(t.default !== undefined ? { default: t.default } : {}),
25
+ ...(c.contract.props?.[name] && c.contract.props[name] !== t.type
26
+ ? { description: c.contract.props[name] }
27
+ : {}),
28
+ }));
29
+ // Required props first; the sort is stable, so the rest keep their order.
30
+ props.sort((a, b) => Number(b.required) - Number(a.required));
31
+ const inherited = data.agent.inherited[c.export] !== undefined;
32
+ return {
33
+ name: c.export,
34
+ id: c.id,
35
+ version: c.version,
36
+ kit: p.kit,
37
+ kitTitle: p.kitTitle,
38
+ category: p.category ?? null,
39
+ categoryTitle: p.categoryTitle ?? null,
40
+ description: c.description,
41
+ import: importLine(data, [c.export]),
42
+ marker: `data-m-component="${c.export}"`,
43
+ props,
44
+ acceptsHtmlAttributes: inherited,
45
+ usage: c.contract.usage,
46
+ avoid: c.contract.avoid,
47
+ accessibility: c.contract.accessibility,
48
+ capabilities: c.capabilities,
49
+ dependencies: (c.contract.dependencies ?? [])
50
+ .map((id) => data.byId(id)?.export ?? id)
51
+ .sort(),
52
+ testing: c.testing,
53
+ templates: data.agent.templates
54
+ .filter((t) => t.components.includes(c.export))
55
+ .map((t) => t.id),
56
+ ...(example ? { example: data.agent.examples[c.export] ?? null } : {}),
57
+ };
58
+ }
59
+
60
+ /** Components and the catalogue components they render, transitively. */
61
+ export function dependencyClosure(data, components) {
62
+ const requiredBy = new Map();
63
+ const queue = [...components];
64
+ const seen = new Set(components.map((c) => c.id));
65
+ while (queue.length) {
66
+ const c = queue.shift();
67
+ for (const id of c.contract.dependencies ?? []) {
68
+ const dep = data.byId(id);
69
+ if (!dep) continue;
70
+ if (!requiredBy.has(dep.export)) requiredBy.set(dep.export, new Set());
71
+ requiredBy.get(dep.export).add(c.export);
72
+ if (!seen.has(id)) {
73
+ seen.add(id);
74
+ queue.push(dep);
75
+ }
76
+ }
77
+ }
78
+ const requested = new Set(components.map((c) => c.export));
79
+ return [...requiredBy]
80
+ .filter(([name]) => !requested.has(name))
81
+ .map(([name, by]) => ({ name, requiredBy: [...by].sort() }))
82
+ .sort((a, b) => a.name.localeCompare(b.name));
83
+ }
84
+
85
+ // What a brief is for, and the shared shell each purpose starts from
86
+ // (principle shell-first).
87
+ const PURPOSES = [
88
+ {
89
+ id: "auth",
90
+ words:
91
+ /\b(sign[\s-]?(in|up|on)|log[\s-]?(in|on)|login|register|registration|password|two[\s-]factor|2fa|mfa|otp|verify email)\b/i,
92
+ shell: "AuthLayout",
93
+ kit: "application",
94
+ guide: "auth-flows",
95
+ reason:
96
+ "AuthLayout is the page for every sign-in and registration step (docs/auth-flows.md).",
97
+ },
98
+ {
99
+ id: "ecommerce",
100
+ words:
101
+ /\b(shop|store|ecommerce|e-commerce|product|cart|basket|bag|checkout|order)s?\b/i,
102
+ shell: "MarketingHeader",
103
+ kit: "ecommerce",
104
+ reason:
105
+ "Storefront pages use the public-site chrome: MarketingHeader with Container and Footer, as the ecommerce templates do.",
106
+ },
107
+ {
108
+ id: "docs",
109
+ words:
110
+ /\b(docs|documentation|api reference|guide|knowledge base|help centre|help center|changelog)\b/i,
111
+ shell: "DocsShell",
112
+ kit: "docs",
113
+ reason: "DocsShell is the shared documentation shell.",
114
+ },
115
+ {
116
+ id: "marketing",
117
+ words:
118
+ /\b(landing|marketing|homepage|home page|website|public site|pricing page|about page|blog|launch|waitlist)\b/i,
119
+ shell: "MarketingHeader",
120
+ kit: "marketing",
121
+ reason:
122
+ "Public pages start from MarketingHeader (principle shell-first), with Footer.",
123
+ },
124
+ {
125
+ id: "application",
126
+ words:
127
+ /\b(app|application|dashboard|admin|console|portal|crm|settings|workspace|back[\s-]?office|internal tool|saas tool|inbox|account)\b/i,
128
+ shell: "AppShell",
129
+ kit: "application",
130
+ reason:
131
+ "Application screens start from AppShell (principle shell-first): navigation, page heading, content.",
132
+ },
133
+ ];
134
+
135
+ /** Pick components for a free-text brief, with the reason for each. */
136
+ export function chooseForBrief(data, brief) {
137
+ const purposes = PURPOSES.filter((p) => p.words.test(brief));
138
+ const kits = new Set(["foundation", ...purposes.map((p) => p.kit)]);
139
+ const chosen = new Map();
140
+ const choose = (name, reason) => {
141
+ const c = data.find(name);
142
+ if (!c) return;
143
+ if (!chosen.has(c.export))
144
+ chosen.set(c.export, { component: c, reasons: [] });
145
+ chosen.get(c.export).reasons.push(reason);
146
+ };
147
+ for (const p of purposes) choose(p.shell, p.reason);
148
+ if (purposes.some((p) => p.shell === "MarketingHeader"))
149
+ choose("Footer", "Public pages close with the shared Footer.");
150
+ let results = searchComponents(data, { query: brief, limit: 40 });
151
+ if (purposes.length)
152
+ results = results.map((r) => ({
153
+ ...r,
154
+ score: kits.has(r.kit) ? r.score : r.score / 2,
155
+ }));
156
+ results.sort((a, b) => b.score - a.score);
157
+ const top = results[0]?.score ?? 0;
158
+ for (const r of results.slice(0, 8))
159
+ if (r.score >= top * 0.6 && r.matched.length)
160
+ choose(r.name, `Matches “${r.matched.join("”, “")}” in the brief.`);
161
+ const templates = searchTemplates(data, brief, 3);
162
+ return { purposes, chosen: [...chosen.values()], templates };
163
+ }
164
+
165
+ /**
166
+ * A build packet for the named components, or for a brief. Unknown names are
167
+ * reported, not fatal.
168
+ */
169
+ export function buildPacket(data, { components: names = [], brief } = {}) {
170
+ const unknown = [];
171
+ let selection = [];
172
+ let reasons = new Map();
173
+ let templates;
174
+ let purposes = [];
175
+ for (const n of names) {
176
+ const c = data.find(n);
177
+ if (c) selection.push(c);
178
+ else unknown.push(n);
179
+ }
180
+ if (brief) {
181
+ const picked = chooseForBrief(data, brief);
182
+ purposes = picked.purposes;
183
+ for (const { component, reasons: why } of picked.chosen) {
184
+ if (!selection.includes(component)) selection.push(component);
185
+ reasons.set(component.export, why);
186
+ }
187
+ templates = picked.templates;
188
+ }
189
+ selection = [...new Set(selection)];
190
+ const requested = new Set(selection.map((c) => c.export));
191
+ // Templates sharing the most components with the selection.
192
+ if (!templates || !templates.length)
193
+ templates = data.agent.templates
194
+ .map((t) => ({
195
+ ...t,
196
+ overlap: t.components.filter((n) => requested.has(n)).length,
197
+ }))
198
+ .filter((t) => t.overlap > 0)
199
+ .sort(
200
+ (a, b) =>
201
+ b.overlap / Math.sqrt(b.components.length) -
202
+ a.overlap / Math.sqrt(a.components.length),
203
+ )
204
+ .slice(0, 3);
205
+ const dependencies = dependencyClosure(data, selection);
206
+ const usesAuth = selection.some((c) => data.placement(c).category === "auth");
207
+ const guides = ["design", "conventions", "theming"];
208
+ if (usesAuth || purposes.some((p) => p.id === "auth"))
209
+ guides.push("auth-flows");
210
+ return {
211
+ package: data.packageName,
212
+ version: data.version,
213
+ ...(brief ? { brief } : {}),
214
+ ...(unknown.length
215
+ ? {
216
+ unknown: unknown.map((n) => ({
217
+ name: n,
218
+ suggestions: searchComponents(data, { query: n, limit: 3 }).map(
219
+ (r) => r.name,
220
+ ),
221
+ })),
222
+ }
223
+ : {}),
224
+ install: `npm install ${data.packageName} react react-dom tailwindcss`,
225
+ css: {
226
+ where: "Your app's Tailwind CSS entry (for example src/index.css).",
227
+ code: CSS_SETUP,
228
+ notes:
229
+ "Import Mortar after Tailwind. The package ships its compiled component styles, so no @source line is needed for Mortar itself. Do not override .m-* classes or [data-m-component] selectors; theme through tokens (get_guide theming).",
230
+ },
231
+ themeProvider: {
232
+ code: THEME_SETUP,
233
+ notes:
234
+ "Render one ThemeProvider at the app root. brand (neutral | green, or your own via brandTokens) and density (compact | comfortable | spacious) are controlled props; appearance changes through AppearanceControl. Pass navigate={(href) => router.push(href)} to route kit links through your router.",
235
+ },
236
+ import: importLine(data, ["ThemeProvider", ...requested]),
237
+ components: selection.map((c) => ({
238
+ ...componentDetail(data, c),
239
+ ...(reasons.has(c.export) ? { why: reasons.get(c.export) } : {}),
240
+ })),
241
+ dependencies: {
242
+ note: "Rendered inside the components above. They need no import unless you use them directly; they are listed so you know which contracts and states you inherit.",
243
+ components: dependencies,
244
+ },
245
+ templates: templates.map((t) => ({
246
+ id: t.id,
247
+ title: t.title,
248
+ description: t.description,
249
+ kind: t.kind,
250
+ components: t.components,
251
+ playground: `?recipe=${t.id}`,
252
+ })),
253
+ principles: data.principles.map((p, i) => ({
254
+ number: i + 1,
255
+ id: p.id,
256
+ instruction: p.instruction,
257
+ })),
258
+ conventions: data.guide("conventions"),
259
+ guides,
260
+ next: [
261
+ "Compose with named imports from the package; keep product names, data, routes and actions in props and slots.",
262
+ "Run `npx mortar audit` (or the audit_app tool) on the application and fix its findings.",
263
+ "Verify in a browser: mobile and desktop, light and dark, keyboard and focus, loading/empty/error states.",
264
+ "If a shared pattern is missing, contribute it to the kit through a GitHub pull request (get_guide contributing) instead of copying or overriding components.",
265
+ ],
266
+ };
267
+ }
package/cli/search.mjs ADDED
@@ -0,0 +1,246 @@
1
+ // Ranked, offline search over the catalogue: component names, kit and category,
2
+ // descriptions, usage notes, capabilities and prop names.
3
+
4
+ const STOP = new Set(
5
+ (
6
+ "a an and are as at be by for from has have i in into is it its of on or " +
7
+ "our the their this to use used using with we want need needs should " +
8
+ "mortar central semantic tokens component components ui page pages " +
9
+ "screen screens show shows build make some that which can my your " +
10
+ "where when what who will would like also each one all any"
11
+ ).split(" "),
12
+ );
13
+
14
+ // Everyday words for what the kit calls something else.
15
+ const SYNONYMS = {
16
+ login: ["sign", "auth"],
17
+ logon: ["sign", "auth"],
18
+ signin: ["sign", "auth"],
19
+ signup: ["sign", "auth", "registration"],
20
+ register: ["sign", "registration", "auth"],
21
+ registration: ["sign", "auth"],
22
+ auth: ["sign", "authentication"],
23
+ authentication: ["sign", "auth"],
24
+ otp: ["code", "verification"],
25
+ verification: ["verify"],
26
+ verify: ["verification"],
27
+ "2fa": ["two", "factor", "code"],
28
+ mfa: ["two", "factor", "code"],
29
+ password: ["password", "auth"],
30
+ dashboard: ["application", "shell", "metric", "stat"],
31
+ admin: ["application", "shell"],
32
+ app: ["application", "shell"],
33
+ landing: ["marketing", "hero"],
34
+ homepage: ["marketing", "hero"],
35
+ website: ["marketing"],
36
+ modal: ["dialog", "overlay"],
37
+ popup: ["dialog", "overlay"],
38
+ drawer: ["slide", "over"],
39
+ sheet: ["slide", "over"],
40
+ dialog: ["modal", "overlay"],
41
+ menu: ["dropdown", "navigation"],
42
+ nav: ["navigation"],
43
+ navbar: ["navigation", "header"],
44
+ topbar: ["navbar", "header"],
45
+ sidenav: ["sidebar", "navigation"],
46
+ toast: ["notification"],
47
+ notification: ["toast", "notice"],
48
+ alert: ["notice", "banner", "callout"],
49
+ grid: ["table", "list"],
50
+ datagrid: ["table", "data"],
51
+ spreadsheet: ["table"],
52
+ kpi: ["metric", "stat"],
53
+ stats: ["stat", "metric"],
54
+ analytics: ["metric", "stat", "chart"],
55
+ shop: ["ecommerce", "product"],
56
+ store: ["ecommerce", "product"],
57
+ basket: ["cart", "bag"],
58
+ bag: ["cart"],
59
+ docs: ["documentation"],
60
+ documentation: ["docs"],
61
+ plans: ["pricing", "plan"],
62
+ plan: ["pricing"],
63
+ faq: ["question"],
64
+ questions: ["faq"],
65
+ testimonials: ["testimonial", "social"],
66
+ reviews: ["review", "testimonial"],
67
+ tooltip: ["popover"],
68
+ toggle: ["switch"],
69
+ spinner: ["loading", "progress"],
70
+ loader: ["loading", "progress"],
71
+ wizard: ["steps", "progress"],
72
+ stepper: ["steps", "progress"],
73
+ upload: ["attachment", "file"],
74
+ files: ["attachment"],
75
+ chat: ["chat", "comment"],
76
+ comments: ["comment", "activity"],
77
+ timeline: ["activity", "feed"],
78
+ schedule: ["calendar"],
79
+ events: ["calendar"],
80
+ search: ["search", "command", "combobox"],
81
+ theme: ["theme", "appearance", "brand"],
82
+ darkmode: ["appearance", "theme"],
83
+ avatar: ["avatar", "person"],
84
+ profile: ["avatar", "description"],
85
+ team: ["team", "people"],
86
+ };
87
+
88
+ const stem = (word) =>
89
+ word.length > 4 && word.endsWith("ies")
90
+ ? word.slice(0, -3) + "y"
91
+ : word.length > 3 && word.endsWith("s") && !word.endsWith("ss")
92
+ ? word.slice(0, -1)
93
+ : word;
94
+
95
+ /** Lower-case terms: camelCase split, punctuation removed, stop words dropped. */
96
+ export function terms(text) {
97
+ return String(text ?? "")
98
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
99
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
100
+ .toLowerCase()
101
+ .split(/[^a-z0-9]+/)
102
+ .filter((w) => w && !STOP.has(w))
103
+ .map(stem);
104
+ }
105
+
106
+ /** Query terms with their weights: typed terms 1, synonyms 0.5. */
107
+ function queryTerms(query) {
108
+ const raw = String(query ?? "").toLowerCase();
109
+ const out = new Map();
110
+ const add = (t, w) => {
111
+ t = stem(t);
112
+ if (!STOP.has(t)) out.set(t, Math.max(out.get(t) ?? 0, w));
113
+ };
114
+ // "sign in", "log in", "sign-up" and "two-factor" read as one idea.
115
+ const joined = raw
116
+ .replace(/\blog[\s-]?(in|on)\b/g, "login")
117
+ .replace(/\bsign[\s-]?in\b/g, "signin sign")
118
+ .replace(/\bsign[\s-]?up\b/g, "signup sign");
119
+ for (const t of terms(joined)) {
120
+ add(t, 1);
121
+ for (const s of SYNONYMS[t] ?? []) add(s, 0.5);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ const FIELDS = [
127
+ ["name", 6],
128
+ ["category", 3],
129
+ ["kit", 2],
130
+ ["description", 2],
131
+ ["usage", 1],
132
+ ["capabilities", 1],
133
+ ["props", 0.5],
134
+ ];
135
+
136
+ function documentFor(data, c) {
137
+ const p = data.placement(c);
138
+ const doc = {
139
+ name: terms(c.export),
140
+ category: terms(`${p.categoryTitle ?? ""} ${p.category ?? ""}`),
141
+ kit: terms(`${p.kitTitle} ${p.kit}`),
142
+ description: terms(c.description),
143
+ usage: terms(`${c.contract.usage ?? ""} ${c.contract.accessibility ?? ""}`),
144
+ capabilities: terms((c.capabilities ?? []).join(" ")),
145
+ props: terms(Object.keys(c.contract.propTypes ?? {}).join(" ")),
146
+ };
147
+ return Object.fromEntries(
148
+ Object.entries(doc).map(([k, v]) => [k, new Set(v)]),
149
+ );
150
+ }
151
+
152
+ const cache = new WeakMap();
153
+
154
+ function score(doc, query) {
155
+ let total = 0;
156
+ const matched = [];
157
+ for (const [term, weight] of query) {
158
+ let best = 0;
159
+ for (const [field, w] of FIELDS) {
160
+ const set = doc[field];
161
+ let hit = set.has(term) ? w : 0;
162
+ if (!hit && term.length >= 4)
163
+ for (const word of set)
164
+ if (word.length > term.length && word.startsWith(term)) {
165
+ hit = w * 0.6;
166
+ break;
167
+ }
168
+ best = Math.max(best, hit);
169
+ }
170
+ if (best) {
171
+ total += best * weight;
172
+ if (weight === 1) matched.push(term);
173
+ }
174
+ }
175
+ return { total, matched };
176
+ }
177
+
178
+ /**
179
+ * Components ranked for a query. Optional kit and category filters accept ids
180
+ * or titles. Each result: name, kit, category, description, capabilities, score.
181
+ */
182
+ export function searchComponents(
183
+ data,
184
+ { query = "", kit, category, limit = 10 } = {},
185
+ ) {
186
+ let docs = cache.get(data);
187
+ if (!docs) {
188
+ docs = new Map(data.components.map((c) => [c, documentFor(data, c)]));
189
+ cache.set(data, docs);
190
+ }
191
+ const q = queryTerms(query);
192
+ const exact = data.find(String(query).replace(/\s+/g, ""));
193
+ const norm = (s) => String(s ?? "").toLowerCase();
194
+ const results = [];
195
+ for (const c of data.components) {
196
+ const p = data.placement(c);
197
+ if (kit && ![p.kit, p.kitTitle].map(norm).includes(norm(kit))) continue;
198
+ if (
199
+ category &&
200
+ ![p.category, p.categoryTitle].map(norm).includes(norm(category))
201
+ )
202
+ continue;
203
+ let { total, matched } = q.size
204
+ ? score(docs.get(c), q)
205
+ : { total: 1, matched: [] };
206
+ if (c === exact) total += 100;
207
+ // Every typed term in the name: "sign in panel" → SignInPanel.
208
+ const typed = [...q].filter(([, w]) => w === 1).map(([t]) => t);
209
+ if (typed.length && typed.every((t) => docs.get(c).name.has(t))) total += 4;
210
+ // Shell-first: a page shell ranks above the parts it holds.
211
+ if (total && p.category === "shells") total += 1;
212
+ if (total > 0)
213
+ results.push({
214
+ name: c.export,
215
+ kit: p.kit,
216
+ category: p.categoryTitle ?? null,
217
+ description: c.description,
218
+ capabilities: c.capabilities,
219
+ score: Math.round(total * 10) / 10,
220
+ matched,
221
+ });
222
+ }
223
+ results.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
224
+ return results.slice(0, Math.max(1, Math.min(Number(limit) || 10, 115)));
225
+ }
226
+
227
+ /** Page templates ranked for a query (title, description, kind and components). */
228
+ export function searchTemplates(data, query, limit = 3) {
229
+ const q = queryTerms(query);
230
+ const out = [];
231
+ for (const t of data.agent.templates) {
232
+ const doc = {
233
+ name: new Set([...terms(`${t.id} ${t.title}`), t.id.replace(/-/g, "")]),
234
+ category: new Set(terms(t.kind)),
235
+ kit: new Set(),
236
+ description: new Set(terms(t.description)),
237
+ usage: new Set(terms(t.components.join(" "))),
238
+ capabilities: new Set(),
239
+ props: new Set(),
240
+ };
241
+ const { total } = score(doc, q);
242
+ if (total > 0) out.push({ ...t, score: Math.round(total * 10) / 10 });
243
+ }
244
+ out.sort((a, b) => b.score - a.score);
245
+ return out.slice(0, limit);
246
+ }