@cosmicdrift/kumiko-headless 0.100.0 → 0.102.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-headless",
3
- "version": "0.100.0",
3
+ "version": "0.102.0",
4
4
  "description": "Headless UI logic for Kumiko — Dispatcher contract, Form-Controller, View-Model, Nav-Resolver. Plattform- und React-frei; jeder Renderer (renderer, renderer-web, renderer-native, …) komponiert darauf.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  },
34
34
  "dependencies": {
35
- "@cosmicdrift/kumiko-framework": "0.100.0",
35
+ "@cosmicdrift/kumiko-framework": "0.102.0",
36
36
  "zod": "^4.4.3"
37
37
  },
38
38
  "publishConfig": {
@@ -53,6 +53,45 @@ describe("renderApexPage", () => {
53
53
  expect(html).toContain("<rect/>");
54
54
  });
55
55
 
56
+ test("nav menu entry renders a dropdown; plain links stay anchors", () => {
57
+ const html = renderApexPage(
58
+ page({
59
+ header: {
60
+ brand: { href: "/", label: "Acme" },
61
+ navLinks: [
62
+ {
63
+ kind: "menu",
64
+ label: "Product",
65
+ items: [
66
+ { icon: "<rect/>", title: "Planner", desc: "Plan it", href: "/p" },
67
+ { title: "<b>Reports</b>", href: "/r" },
68
+ ],
69
+ footer: { label: "See all", href: "/all" },
70
+ },
71
+ { label: "Pricing", href: "#pricing" },
72
+ ],
73
+ },
74
+ }),
75
+ );
76
+ // Menu chrome + trigger label.
77
+ expect(html).toContain('class="nav-menu"');
78
+ expect(html).toContain('class="nav-menu__trigger"');
79
+ expect(html).toContain("Product");
80
+ // Items: title, description, wrapped icon; missing desc/icon simply omitted.
81
+ expect(html).toContain('class="nav-menu__item" href="/p"');
82
+ expect(html).toContain("Planner");
83
+ expect(html).toContain("Plan it");
84
+ expect(html).toContain("<rect/>");
85
+ // Footer link under the divider.
86
+ expect(html).toContain('class="nav-menu__more" href="/all"');
87
+ expect(html).toContain("See all");
88
+ // Item titles are escaped (app-data, not trusted HTML).
89
+ expect(html).toContain("&lt;b&gt;Reports&lt;/b&gt;");
90
+ expect(html).not.toContain("<b>Reports</b>");
91
+ // The sibling plain link still renders as a normal anchor.
92
+ expect(html).toContain('<a href="#pricing">Pricing</a>');
93
+ });
94
+
56
95
  test("pricing: featured card gets badge + featured class, cap line precedes benefits", () => {
57
96
  const html = renderApexPage(
58
97
  page({
package/src/apex/css.ts CHANGED
@@ -189,6 +189,45 @@ const RESPONSIVE = `
189
189
  }
190
190
  `;
191
191
 
192
+ // Dropdown nav entry (kind:"menu"): CSS-only, reveals on hover AND keyboard
193
+ // focus-within (the trigger is a real <button>, panel items are <a>). The panel
194
+ // is a light popover in BOTH themes — only the trigger color tracks the
195
+ // surrounding nav. Nav is hidden < 640px (CHROME_*), so this is desktop-only.
196
+ // Exported standalone so a consumer rendering its own header chrome (not the
197
+ // full apex page) can include just this, without duplicating the rules.
198
+ export const APEX_NAV_MENU_CSS = `
199
+ .nav-menu { position: relative; display: inline-flex; }
200
+ .nav-menu__trigger { display: inline-flex; align-items: center; gap: 0.3rem;
201
+ font: inherit; font-size: 0.9375rem; color: var(--fg-muted);
202
+ background: none; border: 0; padding: 0; cursor: pointer; }
203
+ .nav-menu__trigger:hover { color: var(--fg); }
204
+ .nav-menu__chev { font-size: 0.7em; transition: transform 0.15s; }
205
+ .nav-menu:hover .nav-menu__chev, .nav-menu:focus-within .nav-menu__chev { transform: rotate(180deg); }
206
+ .nav-menu__panel { position: absolute; top: calc(100% + 0.5rem); left: 0; z-index: 20;
207
+ min-width: 21rem; padding: 0.5rem; background: var(--bg-card); color: var(--fg);
208
+ border: 1px solid var(--border); border-radius: 0.75rem; box-shadow: var(--shadow);
209
+ display: flex; flex-direction: column; gap: 0.125rem;
210
+ opacity: 0; visibility: hidden; transform: translateY(0.375rem);
211
+ transition: opacity 0.15s, transform 0.15s, visibility 0.15s; }
212
+ .nav-menu:hover .nav-menu__panel, .nav-menu:focus-within .nav-menu__panel {
213
+ opacity: 1; visibility: visible; transform: translateY(0); }
214
+ .nav-menu__item { display: flex; gap: 0.75rem; align-items: flex-start;
215
+ padding: 0.6rem 0.7rem; border-radius: 0.5rem; color: var(--fg); }
216
+ .nav-menu__item:hover { background: var(--bg-muted); color: var(--fg); }
217
+ .nav-menu__icon { flex: none; display: inline-flex; align-items: center; justify-content: center;
218
+ width: 2rem; height: 2rem; border-radius: 0.5rem;
219
+ background: color-mix(in srgb, var(--primary) 12%, transparent); color: var(--primary); }
220
+ .nav-menu__icon svg { width: 1.1rem; height: 1.1rem; }
221
+ .nav-menu__text { display: flex; flex-direction: column; gap: 0.1rem; }
222
+ .nav-menu__title { font-weight: 600; font-size: 0.9rem; line-height: 1.2; }
223
+ .nav-menu__desc { font-size: 0.8rem; color: var(--fg-muted); line-height: 1.35; }
224
+ .nav-menu__sep { height: 1px; background: var(--border); margin: 0.375rem 0.3rem; }
225
+ .nav-menu__more { display: inline-flex; padding: 0.4rem 0.7rem; font-size: 0.85rem; font-weight: 600; color: var(--primary); }
226
+ .nav-menu__more:hover { color: var(--primary-hover); }
227
+ .apex-dark .nav-menu__trigger { color: var(--on-dark-muted); }
228
+ .apex-dark .nav-menu__trigger:hover { color: var(--on-dark); }
229
+ `;
230
+
192
231
  export const APEX_STRUCTURAL_CSS =
193
232
  BASE_LAYOUT +
194
233
  HERO +
@@ -198,4 +237,5 @@ export const APEX_STRUCTURAL_CSS =
198
237
  FINAL_CTA +
199
238
  CHROME_LIGHT +
200
239
  CHROME_DARK +
240
+ APEX_NAV_MENU_CSS +
201
241
  RESPONSIVE;
package/src/apex/index.ts CHANGED
@@ -7,6 +7,8 @@
7
7
  import { escapeHtml } from "../format";
8
8
  import { APEX_STRUCTURAL_CSS } from "./css";
9
9
 
10
+ export { APEX_NAV_MENU_CSS, APEX_STRUCTURAL_CSS } from "./css";
11
+
10
12
  export type ApexTheme = "light" | "dark";
11
13
 
12
14
  /** Brand is raw CSS the app owns: the :root token block and optional @font-face.
@@ -28,6 +30,28 @@ export type ApexCta = {
28
30
  };
29
31
 
30
32
  export type ApexLink = { readonly label: string; readonly href: string };
33
+
34
+ /** One entry inside a dropdown nav menu: icon + title + optional description. */
35
+ export type ApexNavMenuItem = {
36
+ /** Inner SVG markup (paths), wrapped by the standard 24px icon <svg>. Trusted. */
37
+ readonly icon?: string;
38
+ readonly title: string;
39
+ readonly desc?: string;
40
+ readonly href: string;
41
+ };
42
+
43
+ /** A header nav entry that opens a dropdown (icon/title/desc rows) instead of
44
+ * navigating. CSS-only: reveals on hover + keyboard focus. */
45
+ export type ApexNavMenu = {
46
+ readonly kind: "menu";
47
+ readonly label: string;
48
+ readonly items: readonly ApexNavMenuItem[];
49
+ /** Optional link under a divider at the foot of the panel (e.g. "See all →"). */
50
+ readonly footer?: ApexLink;
51
+ };
52
+
53
+ /** A header nav entry: a plain link, or a dropdown menu. */
54
+ export type ApexNavEntry = ApexLink | ApexNavMenu;
31
55
  export type ApexImage = {
32
56
  readonly src: string;
33
57
  readonly alt: string;
@@ -37,7 +61,8 @@ export type ApexImage = {
37
61
 
38
62
  export type ApexHeader = {
39
63
  readonly brand: { readonly href: string; readonly label: string; readonly logoSrc?: string };
40
- readonly navLinks?: readonly ApexLink[];
64
+ /** Plain links and/or dropdown menus, in order. */
65
+ readonly navLinks?: readonly ApexNavEntry[];
41
66
  readonly actions?: readonly ApexCta[];
42
67
  };
43
68
 
@@ -329,12 +354,37 @@ function renderSection(s: ApexSection): string {
329
354
  }
330
355
  }
331
356
 
332
- function renderHeader(h: ApexHeader): string {
357
+ function renderNavMenu(m: ApexNavMenu): string {
358
+ const items = m.items
359
+ .map(
360
+ (it) =>
361
+ `<a class="nav-menu__item" href="${escapeHtml(it.href)}">${
362
+ it.icon !== undefined ? `<span class="nav-menu__icon">${svgIcon(it.icon)}</span>` : ""
363
+ }<span class="nav-menu__text"><span class="nav-menu__title">${escapeHtml(it.title)}</span>${
364
+ it.desc !== undefined ? `<span class="nav-menu__desc">${escapeHtml(it.desc)}</span>` : ""
365
+ }</span></a>`,
366
+ )
367
+ .join("");
368
+ const footer =
369
+ m.footer !== undefined
370
+ ? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
371
+ : "";
372
+ return `<div class="nav-menu"><button type="button" class="nav-menu__trigger" aria-haspopup="true">${escapeHtml(m.label)}<span class="nav-menu__chev" aria-hidden="true">▾</span></button><div class="nav-menu__panel">${items}${footer}</div></div>`;
373
+ }
374
+
375
+ function renderNavEntry(entry: ApexNavEntry): string {
376
+ return "items" in entry
377
+ ? renderNavMenu(entry)
378
+ : `<a href="${escapeHtml(entry.href)}">${escapeHtml(entry.label)}</a>`;
379
+ }
380
+
381
+ /** Render just the apex header chrome (brand + nav + actions). Exported so a
382
+ * consumer that composes its own page shell (not a full apex page) can reuse
383
+ * the identical header — markup stays single-source. */
384
+ export function renderApexHeader(h: ApexHeader): string {
333
385
  const logo =
334
386
  h.brand.logoSrc !== undefined ? `<img src="${escapeHtml(h.brand.logoSrc)}" alt="" /> ` : "";
335
- const navLinks = (h.navLinks ?? [])
336
- .map((l) => `<a href="${escapeHtml(l.href)}">${escapeHtml(l.label)}</a>`)
337
- .join("\n ");
387
+ const navLinks = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
338
388
  const actions = (h.actions ?? []).map(renderCta);
339
389
  return `<header>
340
390
  <div class="container nav">
@@ -445,7 +495,7 @@ export function renderApexPage(page: ApexPage): string {
445
495
  <style>${css}</style>
446
496
  </head>
447
497
  <body${theme === "dark" ? ` class="apex-dark"` : ""}>
448
- ${renderHeader(page.header)}
498
+ ${renderApexHeader(page.header)}
449
499
 
450
500
  ${sections}
451
501