@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 +3 -2
- package/src/apex/index.ts +18 -18
- package/src/form/form-controller.ts +6 -3
- package/src/format/index.ts +25 -5
- package/src/store/create-store.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-headless",
|
|
3
|
-
"version": "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.
|
|
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
|
|
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
|
-
${
|
|
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
|
|
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
|
-
${
|
|
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
|
|
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
|
-
${
|
|
317
|
+
${itemsHtml}
|
|
318
318
|
</ul>
|
|
319
319
|
${cta}
|
|
320
320
|
</article>`;
|
|
321
321
|
}
|
|
322
322
|
|
|
323
323
|
function renderPricingGrid(s: ApexPricingGridSection): string {
|
|
324
|
-
const
|
|
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
|
-
${
|
|
330
|
+
${cardsHtml}
|
|
331
331
|
</div>
|
|
332
332
|
</div>
|
|
333
333
|
</section>`;
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
function renderInfoGrid(s: ApexInfoGridSection): string {
|
|
337
|
-
const
|
|
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
|
-
${
|
|
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
|
|
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">${
|
|
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
|
|
417
|
-
const
|
|
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
|
-
${
|
|
422
|
-
${
|
|
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
|
|
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
|
-
${
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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({});
|
package/src/format/index.ts
CHANGED
|
@@ -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
|
-
|
|
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")
|
|
25
|
-
|
|
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();
|