@cosmicdrift/kumiko-headless 0.98.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.98.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.98.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
 
@@ -118,6 +118,41 @@ describe("computeListViewModel", () => {
118
118
  ).toThrow(/unknown field "doesNotExist"/);
119
119
  });
120
120
 
121
+ test("labeled column with no matching field → virtual presentational column (no throw)", () => {
122
+ const vm = computeListViewModel({
123
+ screen: listScreen([
124
+ "title",
125
+ { field: "tags", label: "Tags", renderer: { react: { __component: "TagsCell" } } },
126
+ ]),
127
+ entity: taskEntity,
128
+ rows: [],
129
+ translate,
130
+ featureName: "tasks",
131
+ });
132
+ // `field` becomes the column key; label is taken verbatim (translate is
133
+ // identity here), type defaults to text, never server-sortable.
134
+ expect(vm.columns[1]).toEqual({
135
+ field: "tags",
136
+ label: "Tags",
137
+ type: "text",
138
+ sortable: false,
139
+ renderer: { react: { __component: "TagsCell" } },
140
+ });
141
+ });
142
+
143
+ test("label overrides the field-convention header on a real field", () => {
144
+ const vm = computeListViewModel({
145
+ screen: listScreen([{ field: "title", label: "custom.header" }]),
146
+ entity: taskEntity,
147
+ rows: [],
148
+ translate,
149
+ featureName: "tasks",
150
+ });
151
+ // label goes through translate (identity here) instead of the
152
+ // tasks:entity:task:field:title convention key.
153
+ expect(vm.columns[0]?.label).toBe("custom.header");
154
+ });
155
+
121
156
  test("translate is called with the expected i18n-key per field", () => {
122
157
  const spy = mock((key: string) => `T:${key}`);
123
158
  computeListViewModel({
@@ -42,18 +42,31 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
42
42
  // columns carry no reference/select metadata and never server-sort.
43
43
  const derivedDef = entity.derivedFields?.[normalized.field];
44
44
  if (!derivedDef) {
45
- // Screen references a field that's neither stored nor derived. Fail
46
- // loudthe boot-validator (r.screen) should catch this, but a stale
47
- // field-rename would leave the screen referring to a ghost column
48
- // until ops re-runs boot. We throw so the renderer sees the error
49
- // instead of drawing an empty column.
45
+ // A labeled column with no matching stored/derived field is a *virtual*
46
+ // presentational column drawn entirely by a columnRenderer component
47
+ // from the row (e.g. tag chips); value is undefined and it never
48
+ // server-sorts. `field` is just the column key. Without a label it's an
49
+ // author typo (or a stale field-rename) → fail loud so the renderer
50
+ // doesn't silently draw an empty column.
51
+ if (normalized.label !== undefined) {
52
+ columns.push({
53
+ field: normalized.field,
54
+ label: translate(normalized.label),
55
+ type: "text",
56
+ sortable: false,
57
+ ...(normalized.renderer !== undefined && { renderer: normalized.renderer }),
58
+ });
59
+ continue;
60
+ }
50
61
  throw new Error(
51
62
  `computeListViewModel: screen "${screen.id}" references unknown field "${normalized.field}" on entity "${screen.entity}"`,
52
63
  );
53
64
  }
54
65
  columns.push({
55
66
  field: normalized.field,
56
- label: translate(fieldLabelKey(featureName, screen.entity, normalized.field)),
67
+ label: translate(
68
+ normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
69
+ ),
57
70
  type: derivedDef.valueType,
58
71
  // Display-only: a header sort would round-trip to the server, which has
59
72
  // no column to sort by (see DerivedFieldDef). Never offer the affordance.
@@ -62,7 +75,9 @@ export function computeListViewModel(input: ComputeListViewModelInput): ListView
62
75
  });
63
76
  continue;
64
77
  }
65
- const label = translate(fieldLabelKey(featureName, screen.entity, normalized.field));
78
+ const label = translate(
79
+ normalized.label ?? fieldLabelKey(featureName, screen.entity, normalized.field),
80
+ );
66
81
  // Tier 2.7e-3 + Cross-Feature: Reference-Field — entity-String
67
82
  // kann same-feature ("user") oder cross-feature ("users:user")
68
83
  // sein. parseRefTarget gibt (featureName, entityName), der