@cosmicdrift/kumiko-headless 0.155.0 → 0.156.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.155.0",
3
+ "version": "0.156.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,8 @@
36
36
  }
37
37
  },
38
38
  "dependencies": {
39
- "@cosmicdrift/kumiko-framework": "0.155.0",
39
+ "@cosmicdrift/kumiko-framework": "0.156.0",
40
+ "temporal-polyfill": "^0.3.2",
40
41
  "zod": "^4.4.3"
41
42
  },
42
43
  "publishConfig": {
package/src/apex/index.ts CHANGED
@@ -253,7 +253,7 @@ function renderHero(s: ApexHeroSection): string {
253
253
  s.logo !== undefined
254
254
  ? `<img class="hero-pony" src="${escapeHtml(s.logo.src)}" alt="${escapeHtml(s.logo.alt)}"${dim(s.logo)} />`
255
255
  : "";
256
- const ctas = (s.ctas ?? []).map(renderCta).join("\n ");
256
+ const ctasHtml = (s.ctas ?? []).map(renderCta).join("\n ");
257
257
  const meta = s.metaHtml !== undefined ? `<p class="hero-meta">${s.metaHtml}</p>` : "";
258
258
  const visual =
259
259
  s.screenshot !== undefined
@@ -264,7 +264,7 @@ function renderHero(s: ApexHeroSection): string {
264
264
  <div class="hero-copy">
265
265
  ${logo}<h1>${escapeHtml(s.title)}</h1>
266
266
  <p class="tagline">${escapeHtml(s.tagline)}</p>
267
- ${ctas !== "" ? `<div class="hero-cta">${ctas}</div>` : ""}
267
+ ${ctasHtml !== "" ? `<div class="hero-cta">${ctasHtml}</div>` : ""}
268
268
  ${meta}
269
269
  </div>
270
270
  ${visual}
@@ -273,7 +273,7 @@ function renderHero(s: ApexHeroSection): string {
273
273
  }
274
274
 
275
275
  function renderFeatureGrid(s: ApexFeatureGridSection): string {
276
- const cards = s.items
276
+ const cardsHtml = s.items
277
277
  .map(
278
278
  (f) => `<article class="feature">
279
279
  ${f.icon !== undefined ? `<div class="feature-icon">${svgIcon(f.icon)}</div>` : ""}<h3>${escapeHtml(f.title)}</h3>
@@ -285,7 +285,7 @@ function renderFeatureGrid(s: ApexFeatureGridSection): string {
285
285
  <div class="container">
286
286
  ${renderSectionHead(s)}
287
287
  <div class="feature-grid">
288
- ${cards}
288
+ ${cardsHtml}
289
289
  </div>
290
290
  </div>
291
291
  </section>`;
@@ -298,7 +298,7 @@ function renderPricingCard(t: ApexPricingTier): string {
298
298
  const cap =
299
299
  t.capLine !== undefined ? [`<li class="price-cap">${escapeHtml(t.capLine)}</li>`] : [];
300
300
  const benefits = t.benefits.map((b) => `<li>${escapeHtml(b)}</li>`);
301
- const items = [...cap, ...benefits].join("\n ");
301
+ const itemsHtml = [...cap, ...benefits].join("\n ");
302
302
  const per = t.priceSuffix !== undefined ? `<span>${escapeHtml(t.priceSuffix)}</span>` : "";
303
303
  const tagline =
304
304
  t.tagline !== undefined ? `<p class="price-tagline">${escapeHtml(t.tagline)}</p>` : "";
@@ -314,27 +314,27 @@ function renderPricingCard(t: ApexPricingTier): string {
314
314
  ${badge}<h3>${escapeHtml(t.name)}</h3>
315
315
  ${tagline}<div class="price-amount">${escapeHtml(t.amount)}${per}</div>
316
316
  <ul class="price-list">
317
- ${items}
317
+ ${itemsHtml}
318
318
  </ul>
319
319
  ${cta}
320
320
  </article>`;
321
321
  }
322
322
 
323
323
  function renderPricingGrid(s: ApexPricingGridSection): string {
324
- const cards = s.tiers.map(renderPricingCard).join("\n ");
324
+ const cardsHtml = s.tiers.map(renderPricingCard).join("\n ");
325
325
  const idAttr = s.id !== undefined ? ` id="${escapeHtml(s.id)}"` : "";
326
326
  return `<section${idAttr}>
327
327
  <div class="container">
328
328
  ${renderSectionHead(s)}
329
329
  <div class="price-grid">
330
- ${cards}
330
+ ${cardsHtml}
331
331
  </div>
332
332
  </div>
333
333
  </section>`;
334
334
  }
335
335
 
336
336
  function renderInfoGrid(s: ApexInfoGridSection): string {
337
- const items = s.items
337
+ const itemsHtml = s.items
338
338
  .map(
339
339
  (i) => `<div class="trust-item">
340
340
  <h3>${escapeHtml(i.title)}</h3>
@@ -346,7 +346,7 @@ function renderInfoGrid(s: ApexInfoGridSection): string {
346
346
  <div class="container">
347
347
  ${renderSectionHead(s)}
348
348
  <div class="trust-grid">
349
- ${items}
349
+ ${itemsHtml}
350
350
  </div>
351
351
  </div>
352
352
  </section>`;
@@ -384,7 +384,7 @@ function renderSection(s: ApexSection): string {
384
384
  }
385
385
 
386
386
  function renderNavMenu(m: ApexNavMenu): string {
387
- const items = m.items
387
+ const itemsHtml = m.items
388
388
  .map(
389
389
  (it) =>
390
390
  `<a class="nav-menu__item" href="${escapeHtml(it.href)}">${
@@ -398,7 +398,7 @@ function renderNavMenu(m: ApexNavMenu): string {
398
398
  m.footer !== undefined
399
399
  ? `<div class="nav-menu__sep"></div><a class="nav-menu__more" href="${escapeHtml(m.footer.href)}">${escapeHtml(m.footer.label)}</a>`
400
400
  : "";
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">${items}${footer}</div></div>`;
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>`;
402
402
  }
403
403
 
404
404
  function renderNavEntry(entry: ApexNavEntry): string {
@@ -413,13 +413,13 @@ function renderNavEntry(entry: ApexNavEntry): string {
413
413
  export function renderApexHeader(h: ApexHeader): string {
414
414
  const logo =
415
415
  h.brand.logoSrc !== undefined ? `<img src="${escapeHtml(h.brand.logoSrc)}" alt="" /> ` : "";
416
- const navLinks = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
417
- const actions = (h.actions ?? []).map(renderCta);
416
+ const navLinksHtml = (h.navLinks ?? []).map(renderNavEntry).join("\n ");
417
+ const actionsHtml = (h.actions ?? []).map(renderCta).join("\n ");
418
418
  return `<header>
419
419
  <div class="container nav">
420
420
  <div class="brand"><a href="${escapeHtml(h.brand.href)}">${logo}${escapeHtml(h.brand.label)}</a></div>
421
- ${navLinks !== "" ? `<nav class="nav-links">${navLinks}</nav>` : ""}
422
- ${actions.length > 0 ? `<div class="nav-actions">${actions.join("\n ")}</div>` : ""}
421
+ ${navLinksHtml !== "" ? `<nav class="nav-links">${navLinksHtml}</nav>` : ""}
422
+ ${actionsHtml !== "" ? `<div class="nav-actions">${actionsHtml}</div>` : ""}
423
423
  </div>
424
424
  </header>`;
425
425
  }
@@ -527,7 +527,7 @@ export function renderApexPage(page: ApexPage): string {
527
527
  const theme = page.theme ?? "light";
528
528
  // Brand-CSS ist app-authored (Trust-Boundary siehe Datei-Header), kein Tenant-Input.
529
529
  const cssHtml = (brand.fontFaceCss ?? "") + brand.tokensCss + APEX_STRUCTURAL_CSS;
530
- const sections = page.sections.map(renderSection).join("\n\n ");
530
+ const sectionsHtml = page.sections.map(renderSection).join("\n\n ");
531
531
  return `<!doctype html>
532
532
  <html lang="${escapeHtml(head.lang)}">
533
533
  <head>
@@ -539,7 +539,7 @@ export function renderApexPage(page: ApexPage): string {
539
539
  <body${theme === "dark" ? ` class="apex-dark"` : ""}>
540
540
  ${renderApexHeader(page.header)}
541
541
 
542
- ${sections}
542
+ ${sectionsHtml}
543
543
 
544
544
  ${renderFooter(page.footer)}
545
545
 
@@ -197,7 +197,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
197
197
  getSnapshot: snapshotStore.getSnapshot,
198
198
  subscribe: snapshotStore.subscribe,
199
199
  setField(key, value) {
200
- // Skip work when the new value is identical to the current one —
200
+ // skip: value unchanged, avoid notify/re-render on identical set
201
201
  // avoids a notify + re-render for "setField with same value" which
202
202
  // happens a lot in controlled inputs on every keystroke of an
203
203
  // untouched field.
@@ -206,7 +206,7 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
206
206
  invalidate();
207
207
  },
208
208
  setValues(partial) {
209
- // Detect any effective change before rebuilding setValues with a
209
+ // skip: partial matches current values, avoid no-op notify
210
210
  // partial that matches current values shouldn't fire listeners.
211
211
  let changed = false;
212
212
  const v = values as Record<string, unknown>; // @cast-boundary form-values
@@ -217,15 +217,18 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
217
217
  break;
218
218
  }
219
219
  }
220
+ // skip: no key in partial actually changed, avoid no-op notify
220
221
  if (!changed) return;
221
222
  values = { ...values, ...partial };
222
223
  invalidate();
223
224
  },
224
225
  clearErrors(path) {
225
226
  if (path === undefined) {
227
+ // skip: no errors present, avoid no-op notify
226
228
  if (Object.keys(errors).length === 0) return;
227
229
  errors = Object.freeze({});
228
230
  } else {
231
+ // skip: path has no error entry, avoid no-op notify
229
232
  if (!(path in errors)) return;
230
233
  const next: Record<string, readonly FieldIssue[]> = { ...errors };
231
234
  delete next[path];
@@ -239,8 +242,8 @@ export function createFormController<TValues extends FormValues, TCtx = unknown>
239
242
  },
240
243
  validate: runValidate,
241
244
  reset() {
242
- // Cheap no-op when already at baseline and no errors to clear.
243
245
  const alreadyClean = !snapshotStore.getSnapshot().isDirty && Object.keys(errors).length === 0;
246
+ // skip: already at baseline with no errors, no-op reset
244
247
  if (alreadyClean) return;
245
248
  values = { ...initial };
246
249
  errors = Object.freeze({});
@@ -1,6 +1,18 @@
1
1
  // Pure format utilities — no web or platform dependencies.
2
2
  // Shared between renderer-web, renderer-native, and server-side tests.
3
3
 
4
+ import { Temporal } from "temporal-polyfill";
5
+
6
+ function toPlainDate(raw: string): Temporal.PlainDate {
7
+ try {
8
+ return Temporal.PlainDate.from(raw);
9
+ } catch {
10
+ // "date"-typed field stored as a full instant (day-boundary timestamp) —
11
+ // take the calendar date in the local zone rather than fail.
12
+ return Temporal.Instant.from(raw).toZonedDateTimeISO(Temporal.Now.timeZoneId()).toPlainDate();
13
+ }
14
+ }
15
+
4
16
  function formatDateCell(
5
17
  value: unknown,
6
18
  type: string,
@@ -12,17 +24,25 @@ function formatDateCell(
12
24
  ): string {
13
25
  try {
14
26
  const raw = typeof value === "string" ? value : String(value);
15
- const date = new Date(raw);
16
- if (Number.isNaN(date.getTime())) return raw;
17
27
  const locale = opts?.locale;
18
28
  if (opts?.dateStyle || opts?.timeStyle) {
19
- return date.toLocaleString(locale, {
29
+ if (type === "date") {
30
+ // ponytail: timeStyle is ignored here on purpose — a PlainDate has no
31
+ // time component to format, so a timeStyle-only "date" field falls
32
+ // back to PlainDate's default dateStyle instead of rendering a time.
33
+ return toPlainDate(raw).toLocaleString(locale, {
34
+ dateStyle: opts.dateStyle,
35
+ });
36
+ }
37
+ return Temporal.Instant.from(raw).toLocaleString(locale, {
20
38
  dateStyle: opts.dateStyle,
21
39
  timeStyle: opts.timeStyle,
22
40
  });
23
41
  }
24
- if (type === "date") return date.toLocaleDateString(locale);
25
- return date.toLocaleString(locale, {
42
+ if (type === "date") {
43
+ return toPlainDate(raw).toLocaleString(locale);
44
+ }
45
+ return Temporal.Instant.from(raw).toLocaleString(locale, {
26
46
  year: "numeric",
27
47
  month: "short",
28
48
  day: "numeric",
@@ -36,6 +36,7 @@ export function createStore<T>(initial: T): WritableStore<T> {
36
36
  },
37
37
  setState: (next) => {
38
38
  const nextValue = typeof next === "function" ? (next as (prev: T) => T)(snapshot) : next;
39
+ // skip: next value identical to snapshot, avoid notifying listeners
39
40
  if (Object.is(nextValue, snapshot)) return;
40
41
  snapshot = nextValue;
41
42
  for (const listener of listeners) listener();