@cosmicdrift/kumiko-headless 0.200.0 → 0.201.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.200.0",
3
+ "version": "0.201.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>",
@@ -36,7 +36,7 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.200.0",
39
+ "@cosmicdrift/kumiko-framework": "0.201.0",
40
40
  "temporal-polyfill": "^0.3.2",
41
41
  "zod": "^4.4.3"
42
42
  },
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "../css";
2
3
  import { type ApexPage, renderApexPage } from "../index";
3
4
 
4
5
  const brand = { tokensCss: ":root{--primary:#123;--primary-fg:#fff;--bg:#fff;--fg:#000;}" };
@@ -92,6 +93,78 @@ describe("renderApexPage", () => {
92
93
  expect(html).toContain('<a href="#pricing">Pricing</a>');
93
94
  });
94
95
 
96
+ test("nav-links wrap in a mobile hamburger toggle; no navLinks omits it", () => {
97
+ const withNav = renderApexPage(
98
+ page({
99
+ header: {
100
+ brand: { href: "/", label: "Acme" },
101
+ navLinks: [{ label: "Pricing", href: "#pricing" }],
102
+ },
103
+ }),
104
+ );
105
+ expect(withNav).toContain('<details class="nav-toggle">');
106
+ expect(withNav).toContain('<summary class="nav-toggle__trigger" aria-label="Menu">');
107
+ expect(withNav).toContain('<nav class="nav-links">');
108
+
109
+ const customLabel = renderApexPage(
110
+ page({
111
+ header: {
112
+ brand: { href: "/", label: "Acme" },
113
+ navLinks: [{ label: "Pricing", href: "#pricing" }],
114
+ menuLabel: "Menü",
115
+ },
116
+ }),
117
+ );
118
+ expect(customLabel).toContain('aria-label="Menü"');
119
+
120
+ const withoutNav = renderApexPage(page());
121
+ expect(withoutNav).not.toContain('<details class="nav-toggle">');
122
+ });
123
+
124
+ test("mobile-open .nav-toggle rule wins the cascade over the desktop-hidden default", () => {
125
+ // APEX_NAV_MENU_CSS sets `.nav-toggle { display: none; }` unconditionally;
126
+ // APEX_NAV_TOGGLE_RESPONSIVE_CSS re-declares it `.nav .nav-toggle { display:
127
+ // inline-flex; }` inside the 640px query. A markup-only assertion can't catch
128
+ // a reordering that silently restores fw#2047 (nav-links hidden on mobile
129
+ // with no way to reopen them).
130
+ const hiddenDefault = APEX_STRUCTURAL_CSS.indexOf(".nav-toggle { display: none; }");
131
+ const mobileOpen = APEX_STRUCTURAL_CSS.indexOf(".nav .nav-toggle { display: inline-flex; }");
132
+ expect(hiddenDefault).toBeGreaterThan(-1);
133
+ expect(mobileOpen).toBeGreaterThan(-1);
134
+ expect(mobileOpen).toBeGreaterThan(hiddenDefault);
135
+ });
136
+
137
+ test("APEX_NAV_TOGGLE_RESPONSIVE_CSS's mobile-open rules outrank APEX_NAV_MENU_CSS's hidden default regardless of concatenation order (fw#2125)", () => {
138
+ // money-horse/show-pony/the docs sample call renderApexHeader() standalone and
139
+ // concatenate APEX_NAV_MENU_CSS with their own page CSS in whatever order they
140
+ // choose — they don't control ordering relative to APEX_STRUCTURAL_CSS's
141
+ // internal layout. The "show" rules are therefore specificity-hardened
142
+ // (`.nav .nav-toggle`, 2 classes) rather than relying on source order to beat
143
+ // APEX_NAV_MENU_CSS's bare `.nav-toggle` default (1 class) — verify both carry
144
+ // that extra `.nav ` prefix so the fix holds even when a consumer emits
145
+ // APEX_NAV_TOGGLE_RESPONSIVE_CSS BEFORE APEX_NAV_MENU_CSS.
146
+ expect(APEX_NAV_MENU_CSS).toContain(".nav-toggle { display: none; }");
147
+ expect(APEX_NAV_MENU_CSS).toContain(".nav-toggle__trigger { display: none;");
148
+ expect(APEX_NAV_MENU_CSS).not.toContain(".nav-toggle { display: inline-flex");
149
+ expect(APEX_NAV_MENU_CSS).not.toContain(".nav-toggle__trigger { display: inline-flex");
150
+
151
+ expect(APEX_NAV_TOGGLE_RESPONSIVE_CSS).toContain(".nav .nav-toggle { display: inline-flex; }");
152
+ expect(APEX_NAV_TOGGLE_RESPONSIVE_CSS).toContain(
153
+ ".nav .nav-toggle__trigger { display: inline-flex; }",
154
+ );
155
+ });
156
+
157
+ test("APEX_NAV_TOGGLE_RESPONSIVE_CSS declares .nav as the positioned ancestor for the open dropdown", () => {
158
+ // `.nav-toggle[open] > .nav-links` is `position: absolute; top: calc(100% +
159
+ // 0.5rem); left: 0; right: 0` — it needs `.nav { position: relative; }` as
160
+ // its containing block. renderApexPage gets that from CHROME_LIGHT, but a
161
+ // standalone renderApexHeader() consumer's own header CSS typically doesn't
162
+ // declare it, and without it the dropdown opens off-screen against the
163
+ // viewport instead of under the header (fw#2125 follow-up: verified none of
164
+ // money-horse/show-pony/the docs sample's HEADER_CSS set it).
165
+ expect(APEX_NAV_TOGGLE_RESPONSIVE_CSS).toContain(".nav { position: relative; }");
166
+ });
167
+
95
168
  test("pricing: featured card gets badge + featured class, cap line precedes benefits", () => {
96
169
  const html = renderApexPage(
97
170
  page({
package/src/apex/css.ts CHANGED
@@ -126,7 +126,7 @@ const CHROME_LIGHT = `
126
126
  background: color-mix(in srgb, var(--bg) 85%, transparent);
127
127
  backdrop-filter: saturate(140%) blur(8px);
128
128
  border-bottom: 1px solid var(--border); }
129
- .nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 0.85rem; }
129
+ .nav { position: relative; display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 0.85rem; }
130
130
  .brand { display: flex; align-items: center; gap: 0.55rem; font-weight: 700; font-size: 1.125rem; color: var(--fg); }
131
131
  .brand a { color: var(--fg); display: inline-flex; align-items: center; gap: 0.55rem; }
132
132
  .brand a:hover { color: var(--fg); }
@@ -199,29 +199,39 @@ const RESPONSIVE = `
199
199
  }
200
200
  `;
201
201
 
202
- // Dropdown nav entry (kind:"menu"): CSS-only, reveals on hover AND keyboard
203
- // focus-within (the trigger is a real <button>, panel items are <a>). The panel
204
- // is a light popover in BOTH themes only the trigger color tracks the
205
- // surrounding nav. Nav is hidden < 640px (CHROME_*), so this is desktop-only.
206
- // Exported standalone so a consumer rendering its own header chrome (not the
207
- // full apex page) can include just this, without duplicating the rules.
202
+ // header renders navLinks TWICE (renderApexHeader): a plain always-visible
203
+ // .nav-links row, plus a copy nested in .nav-toggle for < 640px — a closed
204
+ // <details> can't be forced "always open" on desktop via CSS (Chrome keeps
205
+ // its content unpainted regardless of a child's own `display`), so two
206
+ // copies sidestep that instead of fighting it. Exported standalone so a
207
+ // consumer rendering its own header chrome can include just this. A
208
+ // consumer using renderApexHeader() outside renderApexPage also needs
209
+ // APEX_NAV_TOGGLE_RESPONSIVE_CSS below for the mobile toggle to open.
208
210
  export const APEX_NAV_MENU_CSS = `
211
+ .nav-toggle { display: none; }
212
+ .nav-toggle__trigger { display: none; align-items: center; justify-content: center;
213
+ width: 2.25rem; height: 2.25rem; border-radius: 0.5rem; cursor: pointer;
214
+ color: var(--fg-muted); list-style: none; }
215
+ .nav-toggle__trigger::-webkit-details-marker { display: none; }
216
+ .nav-toggle__trigger:hover { color: var(--fg); background: var(--bg-muted); }
217
+ .nav-toggle__trigger svg { width: 1.35rem; height: 1.35rem; }
218
+ .apex-dark .nav-toggle__trigger { color: var(--on-dark-muted); }
219
+ .apex-dark .nav-toggle__trigger:hover { color: var(--on-dark); background: rgba(255,255,255,0.08); }
220
+
209
221
  .nav-menu { position: relative; display: inline-flex; }
210
222
  .nav-menu__trigger { display: inline-flex; align-items: center; gap: 0.3rem;
211
223
  font: inherit; font-size: 0.9375rem; color: var(--fg-muted);
212
- background: none; border: 0; padding: 0; cursor: pointer; }
224
+ cursor: pointer; list-style: none; }
225
+ .nav-menu__trigger::-webkit-details-marker { display: none; }
213
226
  .nav-menu__trigger:hover { color: var(--fg); }
214
227
  .nav-menu__chev { display: inline-flex; transition: transform 0.15s; opacity: 0.7; }
215
228
  .nav-menu__chev svg { width: 0.95em; height: 0.95em; }
216
- .nav-menu:hover .nav-menu__chev, .nav-menu:focus-within .nav-menu__chev { transform: rotate(180deg); }
229
+ .nav-menu[open] .nav-menu__chev { transform: rotate(180deg); }
217
230
  .nav-menu__panel { position: absolute; top: calc(100% + 0.5rem); left: 0; z-index: 20;
218
231
  min-width: 21rem; padding: 0.5rem; background: var(--bg-card); color: var(--fg);
219
232
  border: 1px solid var(--border); border-radius: 0.75rem; box-shadow: var(--shadow);
220
- display: flex; flex-direction: column; gap: 0.125rem;
221
- opacity: 0; visibility: hidden; transform: translateY(0.375rem);
222
- transition: opacity 0.15s, transform 0.15s, visibility 0.15s; }
223
- .nav-menu:hover .nav-menu__panel, .nav-menu:focus-within .nav-menu__panel {
224
- opacity: 1; visibility: visible; transform: translateY(0); }
233
+ flex-direction: column; gap: 0.125rem; }
234
+ .nav-menu[open] .nav-menu__panel { display: flex; }
225
235
  .nav-menu__item { display: flex; gap: 0.75rem; align-items: flex-start;
226
236
  padding: 0.6rem 0.7rem; border-radius: 0.5rem; color: var(--fg); }
227
237
  .nav-menu__item:hover { background: var(--bg-muted); color: var(--fg); }
@@ -247,6 +257,38 @@ export const APEX_NAV_MENU_CSS = `
247
257
  .nav-menu__panel a.nav-menu__more:hover { color: var(--primary-hover); }
248
258
  `;
249
259
 
260
+ // Standalone mobile-open rules for .nav-toggle, split out of RESPONSIVE so a
261
+ // consumer calling renderApexHeader() outside renderApexPage (money-horse,
262
+ // show-pony, the docs sample) can include just this alongside
263
+ // APEX_NAV_MENU_CSS, without pulling in the rest of RESPONSIVE's page-level
264
+ // grid breakpoints. The two "show" rules use the `.nav ` prefix (matching
265
+ // renderApexHeader's `<div class="container nav">` wrapper) to raise their
266
+ // specificity above APEX_NAV_MENU_CSS's bare `.nav-toggle`/`.nav-toggle__trigger`
267
+ // "display: none" defaults — that makes them win regardless of concatenation
268
+ // order, since a standalone consumer controls the order, not this file.
269
+ // Also declares `.nav { position: relative; }`: the open dropdown positions
270
+ // itself against .nav (`top/left/right` below), and renderApexPage's own
271
+ // CHROME_LIGHT already sets that, but a standalone consumer's own header CSS
272
+ // generally doesn't — without it the dropdown would anchor to the viewport
273
+ // instead of the header and open off-screen. Redeclaring it here is a no-op
274
+ // inside APEX_STRUCTURAL_CSS (same value, harmless if repeated).
275
+ export const APEX_NAV_TOGGLE_RESPONSIVE_CSS = `
276
+ @media (max-width: 640px) {
277
+ .nav { position: relative; }
278
+ .nav .nav-toggle { display: inline-flex; }
279
+ .nav .nav-toggle__trigger { display: inline-flex; }
280
+ .nav-toggle[open] > .nav-links { display: flex; flex-direction: column; align-items: stretch;
281
+ gap: 0.25rem; position: absolute; top: calc(100% + 0.5rem); left: 0; right: 0; z-index: 30;
282
+ background: var(--bg-card); border: 1px solid var(--border); border-radius: 0.75rem;
283
+ box-shadow: var(--shadow); padding: 0.75rem; }
284
+ .nav-toggle[open] > .nav-links > a { color: var(--fg); padding: 0.4rem 0.25rem; }
285
+ .nav-toggle[open] > .nav-links > a:hover { color: var(--fg); }
286
+ .nav-toggle .nav-menu { display: block; }
287
+ .nav-toggle .nav-menu__panel { position: static; min-width: 0;
288
+ margin-top: 0.375rem; box-shadow: none; }
289
+ }
290
+ `;
291
+
250
292
  export const APEX_STRUCTURAL_CSS =
251
293
  BASE_LAYOUT +
252
294
  HERO +
@@ -257,4 +299,5 @@ export const APEX_STRUCTURAL_CSS =
257
299
  CHROME_LIGHT +
258
300
  CHROME_DARK +
259
301
  APEX_NAV_MENU_CSS +
302
+ APEX_NAV_TOGGLE_RESPONSIVE_CSS +
260
303
  RESPONSIVE;
package/src/apex/index.ts CHANGED
@@ -8,7 +8,7 @@ import { escapeHtml } from "../format";
8
8
  import { APEX_STRUCTURAL_CSS } from "./css";
9
9
  import { APEX_LIGHTBOX_HTML, APEX_LIGHTBOX_SCRIPT } from "./lightbox";
10
10
 
11
- export { APEX_NAV_MENU_CSS, APEX_STRUCTURAL_CSS } from "./css";
11
+ export { APEX_NAV_MENU_CSS, APEX_NAV_TOGGLE_RESPONSIVE_CSS, APEX_STRUCTURAL_CSS } from "./css";
12
12
  export {
13
13
  APEX_LIGHTBOX_HTML,
14
14
  APEX_LIGHTBOX_SCRIPT,
@@ -81,6 +81,8 @@ export type ApexHeader = {
81
81
  /** Plain links and/or dropdown menus, in order. */
82
82
  readonly navLinks?: readonly ApexNavEntry[];
83
83
  readonly actions?: readonly ApexCta[];
84
+ /** aria-label for the mobile hamburger trigger (< 640px). Default "Menu". */
85
+ readonly menuLabel?: string;
84
86
  };
85
87
 
86
88
  export type ApexFooterColumn = { readonly heading: string; readonly links: readonly ApexLink[] };
@@ -398,7 +400,7 @@ function renderNavMenu(m: ApexNavMenu): string {
398
400
  m.footer !== undefined
399
401
  ? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
400
402
  : "";
401
- return `<div class="nav-menu"><button type="button" class="nav-menu__trigger" aria-haspopup="true">${escapeHtml(m.label)}<span class="nav-menu__chev">${svgIcon('<path d="m6 9 6 6 6-6"/>')}</span></button><div class="nav-menu__panel">${itemsHtml}${footer}</div></div>`;
403
+ return `<details class="nav-menu"><summary class="nav-menu__trigger">${escapeHtml(m.label)}<span class="nav-menu__chev">${svgIcon('<path d="m6 9 6 6 6-6"/>')}</span></summary><div class="nav-menu__panel">${itemsHtml}${footer}</div></details>`;
402
404
  }
403
405
 
404
406
  function renderNavEntry(entry: ApexNavEntry): string {
@@ -418,7 +420,7 @@ export function renderApexHeader(h: ApexHeader): string {
418
420
  return `<header>
419
421
  <div class="container nav">
420
422
  <div class="brand"><a href="${escapeHtml(h.brand.href)}">${logo}${escapeHtml(h.brand.label)}</a></div>
421
- ${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav>` : ""}
423
+ ${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav><details class="nav-toggle"><summary class="nav-toggle__trigger" aria-label="${escapeHtml(h.menuLabel ?? "Menu")}">${svgIcon('<path d="M4 6h16M4 12h16M4 18h16"/>')}</summary><nav class="nav-links">${navLinksHtml}</nav></details>` : ""}
422
424
  ${actionsHtml !== "" ? `<div class="nav-actions">${actionsHtml}</div>` : ""}
423
425
  </div>
424
426
  </header>`;