@tokenoftrust/storefront-runner 2.2.126 → 2.3.1

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.
Files changed (109) hide show
  1. package/apps/storefront/package.json +3 -1
  2. package/apps/storefront/public/shared/commerce-chrome.css +57 -15
  3. package/apps/storefront/public/shared/commerce-marketing.css +7 -7
  4. package/apps/storefront/src/components/Badge.astro +1 -1
  5. package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
  6. package/apps/storefront/src/components/Section.astro +4 -1
  7. package/apps/storefront/src/components/Seo.astro +7 -5
  8. package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
  9. package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
  10. package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
  11. package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
  12. package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
  13. package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
  14. package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
  15. package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
  16. package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
  17. package/apps/storefront/src/components/content/ProseSections.astro +2 -2
  18. package/apps/storefront/src/components/home/Hero.astro +34 -12
  19. package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
  20. package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
  21. package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
  22. package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
  23. package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
  24. package/apps/storefront/src/components/marketing/Faq.astro +1 -1
  25. package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
  26. package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
  27. package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
  28. package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
  29. package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
  30. package/apps/storefront/src/components/marketing/Steps.astro +1 -1
  31. package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
  32. package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
  33. package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
  34. package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
  35. package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
  36. package/apps/storefront/src/components/plp/Pagination.astro +1 -1
  37. package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
  38. package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
  39. package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
  40. package/apps/storefront/src/config/devTenantSeed.ts +23 -34
  41. package/apps/storefront/src/config/resolver.ts +0 -38
  42. package/apps/storefront/src/layouts/Layout.astro +8 -1
  43. package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
  44. package/apps/storefront/src/lib/blog/pagination.ts +20 -10
  45. package/apps/storefront/src/lib/blog/presentation.ts +94 -0
  46. package/apps/storefront/src/lib/blog/rss.ts +1 -1
  47. package/apps/storefront/src/lib/blog/types.ts +24 -7
  48. package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
  49. package/apps/storefront/src/lib/checkoutCommerce.ts +26 -3
  50. package/apps/storefront/src/lib/chrome/model.ts +4 -0
  51. package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
  52. package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
  53. package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
  54. package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
  55. package/apps/storefront/src/lib/jsonld.ts +5 -5
  56. package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
  57. package/apps/storefront/src/lib/rawChrome.ts +74 -12
  58. package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
  59. package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
  60. package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
  61. package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
  62. package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
  63. package/apps/storefront/src/pages/[...slug].astro +13 -8
  64. package/apps/storefront/src/pages/blog/[slug].astro +56 -24
  65. package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
  66. package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
  67. package/apps/storefront/src/pages/blog/index.astro +2 -0
  68. package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
  69. package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
  70. package/apps/storefront/src/pages/collections/[handle].astro +5 -4
  71. package/apps/storefront/src/pages/collections/index.astro +4 -3
  72. package/apps/storefront/src/pages/index.astro +34 -27
  73. package/apps/storefront/src/pages/products/[handle].astro +5 -4
  74. package/apps/storefront/src/pages/saved.astro +4 -3
  75. package/apps/storefront/src/pages/search.astro +4 -3
  76. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
  77. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
  78. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
  79. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
  80. package/apps/storefront/src/pages/style-guide/index.astro +6 -5
  81. package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
  82. package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
  83. package/apps/storefront/src/pages/the-build/index.astro +2 -0
  84. package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
  85. package/apps/storefront/src/styles/admin.css +47 -0
  86. package/apps/storefront/src/styles/global.css +104 -56
  87. package/apps/storefront/src/themes/derivedTokens.ts +34 -0
  88. package/apps/storefront/src/themes/schema.ts +6 -0
  89. package/apps/storefront/test/pipeline/harness.ts +164 -0
  90. package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
  91. package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
  92. package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
  93. package/apps/storefront/vitest.workers.config.cts +67 -0
  94. package/docs/widget-library-guide.md +1496 -0
  95. package/package.json +1 -1
  96. package/packages/cli/src/declared-config.mjs +400 -5
  97. package/packages/public-runtime/package.json +1 -0
  98. package/packages/public-runtime/src/checkout.ts +13 -0
  99. package/packages/public-runtime/src/chrome.ts +93 -2
  100. package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
  101. package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
  102. package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
  103. package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
  104. package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
  105. package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
  106. package/packages/public-runtime/src/extension-contract.ts +2 -0
  107. package/packages/public-runtime/src/tenant.ts +48 -4
  108. package/pnpm-lock.runner.yaml +469 -0
  109. package/scripts/tenant/validate.mjs +27 -1
@@ -11,40 +11,50 @@
11
11
  * the vars come from the tenant. Swap a tenant -> the whole UI reskins, no
12
12
  * component edits.
13
13
  *
14
- * Fallback values below keep the design coherent before/without the injected
15
- * block (e.g. error pages, prerendered routes).
14
+ * The values below are the platform defaults. They live in Tailwind's `theme`
15
+ * layer, so the tenant's injected (unlayered) :root block wins wherever it sets
16
+ * a token, and an unset token keeps the default here. A declaration here must
17
+ * never read its own name (`--x: var(--x, …)`): that is a dependency cycle, and
18
+ * a cycle makes the token invalid instead of falling back. Derived tokens read
19
+ * OTHER tokens, so they re-derive from whatever the tenant supplied.
16
20
  */
17
21
  @theme {
18
- --color-bg: var(--color-bg, #f4f1ea);
19
- --color-surface: var(--color-surface, #fcfaf5);
20
- --color-text: var(--color-text, #1c2624);
21
- --color-muted: var(--color-muted, #5c6663);
22
- --color-primary: var(--color-primary, #1f3a34);
23
- --color-primary-contrast: var(--color-primary-contrast, #f4f1ea);
24
- --color-accent: var(--color-accent, #b5612f);
25
- /* Darkened accent for SMALL text/links on light bg/surface (the bright
26
- `accent` is reserved for large display type on the spruce hero, where it
27
- reads as the brass spark). Derived from the tenant's own accent so it stays
28
- theme-agnostic. Reference value resolves to ~#985127 (≥5:1 on bg/surface).
29
- A tenant MAY hand-tune it for exact AA by emitting --color-accent-ink; else
30
- it derives from the tenant's own accent so it stays theme-agnostic. */
31
- --color-accent-ink: var(--color-accent-ink, color-mix(in srgb, var(--color-accent) 84%, #000));
32
- --color-border: var(--color-border, #dcd6c9);
33
- --color-success: var(--color-success, #3f6b4f);
34
- --color-sale: var(--color-sale, #9e3b2e);
22
+ --color-bg: #f4f1ea;
23
+ --color-surface: #fcfaf5;
24
+ --color-text: #1c2624;
25
+ --color-muted: #5c6663;
26
+ --color-primary: #1f3a34;
27
+ --color-primary-contrast: #f4f1ea;
28
+ --color-accent: #b5612f;
29
+ /* Accent for SMALL text/links on light bg/surface (the bright `accent` is
30
+ reserved for fills and large display type on dark grounds). Derived from the
31
+ tenant's own accent by capping its OKLCH lightness at 0.5, which keeps
32
+ ≥5:1 on white and on light bg/surface for any hue — a light accent (gold,
33
+ amber, cyan) included. The color-mix value is the fallback for browsers
34
+ without relative color syntax (see the @supports block below). A tenant
35
+ hand-tunes it by emitting --color-accent-ink. */
36
+ --color-accent-ink: color-mix(in srgb, var(--color-accent) 84%, #000);
37
+ /* Readable text/icon color ON an `accent` fill (badges, accent buttons, the
38
+ accent announcement bar). Derived: black or white, whichever the accent's
39
+ lightness favours — a light accent gets dark text, a deep one light text.
40
+ A tenant hand-tunes it by emitting --color-accent-contrast. */
41
+ --color-accent-contrast: var(--color-primary-contrast);
42
+ --color-border: #dcd6c9;
43
+ --color-success: #3f6b4f;
44
+ --color-sale: #9e3b2e;
35
45
 
36
46
  /* Semantic/state extensions (token-contract.md). Platform defaults kept
37
47
  resolvable here so components can always read them; a tenant theme overrides
38
48
  any by emitting the matching --color-* via themeToCssVars. */
39
- --color-surface-2: var(--color-surface-2, #ece7db);
40
- --color-warning: var(--color-warning, #8a5717);
41
- --color-error: var(--color-error, #a22014);
42
- --color-info: var(--color-info, #2c5b73);
49
+ --color-surface-2: #ece7db;
50
+ --color-warning: #8a5717;
51
+ --color-error: #a22014;
52
+ --color-info: #2c5b73;
43
53
 
44
54
  /* Hover policy: components read --color-{primary,accent}-hover; the default is
45
55
  a derived darken of the base, overridable per tenant for hand-tuned values. */
46
- --color-primary-hover: var(--color-primary-hover, color-mix(in srgb, var(--color-primary) 88%, #000));
47
- --color-accent-hover: var(--color-accent-hover, color-mix(in srgb, var(--color-accent) 88%, #000));
56
+ --color-primary-hover: color-mix(in srgb, var(--color-primary) 88%, #000);
57
+ --color-accent-hover: color-mix(in srgb, var(--color-accent) 88%, #000);
48
58
 
49
59
  /* Interaction/quality-bar tokens (token-contract.md · unit F6). Each carries a
50
60
  derived default so a component may read it unconditionally; a theme sets one
@@ -53,11 +63,11 @@
53
63
  - disabled/-contrast disabled+loading control fill / text-on-disabled
54
64
  - field-border stronger ≥3:1 input outline than the hairline border
55
65
  - star review/rating star fill (trust/UGC surfaces) */
56
- --color-focus-ring: var(--color-focus-ring, var(--color-accent));
57
- --color-disabled: var(--color-disabled, color-mix(in srgb, var(--color-muted) 26%, var(--color-surface)));
58
- --color-disabled-contrast: var(--color-disabled-contrast, var(--color-muted));
59
- --color-field-border: var(--color-field-border, color-mix(in srgb, var(--color-border) 52%, var(--color-text)));
60
- --color-star: var(--color-star, var(--color-accent));
66
+ --color-focus-ring: var(--color-accent);
67
+ --color-disabled: color-mix(in srgb, var(--color-muted) 26%, var(--color-surface));
68
+ --color-disabled-contrast: var(--color-muted);
69
+ --color-field-border: color-mix(in srgb, var(--color-border) 52%, var(--color-text));
70
+ --color-star: var(--color-accent);
61
71
 
62
72
  /* Conversion-role aliases (style-concept.md §Semantic tokens). The palette
63
73
  above names a COLOR; these name the JOB a color does on a conversion
@@ -67,25 +77,37 @@
67
77
  differently until a tenant supplies one — a tenant brand-tunes by emitting
68
78
  color.cta / color.cta-hover / color.cta-contrast / color.trust, which
69
79
  themeToCssVars serializes like any other --color-<k>. */
70
- --color-cta: var(--color-cta, var(--color-primary));
71
- --color-cta-hover: var(--color-cta-hover, var(--color-primary-hover));
72
- --color-cta-contrast: var(--color-cta-contrast, var(--color-primary-contrast));
73
- --color-trust: var(--color-trust, var(--color-success));
80
+ --color-cta: var(--color-primary);
81
+ --color-cta-hover: var(--color-primary-hover);
82
+ --color-cta-contrast: var(--color-primary-contrast);
83
+ --color-trust: var(--color-success);
74
84
 
75
- --font-display: var(--font-display, "Space Grotesk"), system-ui, sans-serif;
76
- --font-body: var(--font-body, "Source Serif 4"), Georgia, serif;
77
- --font-mono: var(--font-mono, "Space Mono"), ui-monospace, monospace;
85
+ --font-display: "Space Grotesk", system-ui, sans-serif;
86
+ --font-body: "Source Serif 4", Georgia, serif;
87
+ --font-mono: "Space Mono", ui-monospace, monospace;
78
88
 
79
- --radius-sm: var(--radius-sm, 3px);
80
- --radius-md: var(--radius-md, 6px);
81
- --radius-lg: var(--radius-lg, 12px);
82
- --radius-full: var(--radius-full, 999px);
89
+ --radius-sm: 3px;
90
+ --radius-md: 6px;
91
+ --radius-lg: 12px;
92
+ --radius-full: 999px;
83
93
 
84
- --shadow-sm: var(--shadow-sm, 0 1px 2px rgba(28, 38, 36, 0.06));
85
- --shadow-md: var(--shadow-md, 0 6px 24px -8px rgba(28, 38, 36, 0.14));
86
- --shadow-lg: var(--shadow-lg, 0 24px 60px -20px rgba(28, 38, 36, 0.22));
94
+ --shadow-sm: 0 1px 2px rgba(28, 38, 36, 0.06);
95
+ --shadow-md: 0 6px 24px -8px rgba(28, 38, 36, 0.14);
96
+ --shadow-lg: 0 24px 60px -20px rgba(28, 38, 36, 0.22);
87
97
 
88
- --container-max: var(--container-max, 1240px);
98
+ --container-max: 1240px;
99
+ }
100
+
101
+ /* Accent-derived tokens in relative color syntax: exact for every hue, where the
102
+ @theme fallbacks above are an approximation. Same layer as @theme, so a
103
+ tenant-supplied value still wins. */
104
+ @layer theme {
105
+ @supports (color: oklch(from red l c h)) {
106
+ :root {
107
+ --color-accent-ink: oklch(from var(--color-accent) min(l, 0.5) c h);
108
+ --color-accent-contrast: oklch(from var(--color-accent) clamp(0, (0.58 - l) * 1000, 1) 0 0);
109
+ }
110
+ }
89
111
  }
90
112
 
91
113
  /* ---- base ---------------------------------------------------------------- */
@@ -94,10 +116,10 @@
94
116
  /* Fluid modular type scale; overridden by injected --fs-* when present. */
95
117
  color-scheme: light;
96
118
  /* Minimum interactive tap-target (WCAG 2.2 §2.5.8). Non-@theme token (a size,
97
- not a Tailwind color) so it lives here with a self-referential default: the
98
- injected --tap-target (schema.ts, from shape.tapTarget) wins when present,
99
- else 44px. Components size touch controls with var(--tap-target). (F6.) */
100
- --tap-target: var(--tap-target, 44px);
119
+ not a Tailwind color) so it lives here, in the base layer: the injected
120
+ --tap-target (schema.ts, from shape.tapTarget) wins when present, else
121
+ 44px. Components size touch controls with var(--tap-target). (F6.) */
122
+ --tap-target: 44px;
101
123
 
102
124
  /* Role aliases that are NOT colors and so cannot live in @theme (Tailwind
103
125
  v4 owns the --text-* namespace for font sizes — a --text-muted there would
@@ -240,6 +262,22 @@
240
262
  color: var(--color-muted);
241
263
  }
242
264
 
265
+ /* Home hero status pill (HeroBlock.notice). Layered, so unlayered tenant CSS
266
+ on `.hero__notice` restyles it without !important. */
267
+ .hero__notice {
268
+ display: inline-flex;
269
+ align-items: center;
270
+ max-width: 100%;
271
+ border: 1px solid var(--color-border);
272
+ border-radius: var(--radius-full);
273
+ background: var(--color-surface);
274
+ color: var(--color-text);
275
+ padding: .35rem .9rem;
276
+ font-family: var(--font-body);
277
+ font-size: .875rem;
278
+ line-height: 1.4;
279
+ }
280
+
243
281
  /* Eyebrow with the signature leading tick — a single short amber rule, the
244
282
  "one brass rivet" motif from DESIGN.md, applied as a quiet structural mark.
245
283
  Opt-in via .eyebrow.eyebrow--tick so it never fires where it'd read as noise. */
@@ -258,35 +296,44 @@
258
296
 
259
297
  /* Fluid display scale, derived from the tenant's emitted modular --fs-* steps
260
298
  (the major-third scale in DESIGN.md). One rhythm across every page instead
261
- of ad-hoc per-component clamps. min = small step, max = large step. */
299
+ of ad-hoc per-component clamps. min = small step, max = large step.
300
+ Each class names its fluid size --display-size; a theme's fixed heading size
301
+ (type.headings → --heading-hero / --heading-h1..h3) replaces it per level. */
262
302
  .display-hero {
263
303
  font-family: var(--font-display);
264
304
  font-weight: 600;
265
305
  line-height: 0.95;
266
306
  letter-spacing: -0.03em;
267
- font-size: clamp(var(--fs-3xl, 3.05rem), 8vw, var(--fs-5xl, 4.77rem));
307
+ --display-size: clamp(var(--fs-3xl, 3.05rem), 8vw, var(--fs-5xl, 4.77rem));
308
+ font-size: var(--heading-hero, var(--display-size));
268
309
  }
269
310
  .display-xl {
270
311
  font-family: var(--font-display);
271
312
  font-weight: 600;
272
313
  line-height: 1.02;
273
314
  letter-spacing: -0.025em;
274
- font-size: clamp(var(--fs-2xl, 2.44rem), 5vw, var(--fs-4xl, 3.81rem));
315
+ --display-size: clamp(var(--fs-2xl, 2.44rem), 5vw, var(--fs-4xl, 3.81rem));
316
+ font-size: var(--display-size);
275
317
  }
276
318
  .display-lg {
277
319
  font-family: var(--font-display);
278
320
  font-weight: 600;
279
321
  line-height: 1.05;
280
322
  letter-spacing: -0.02em;
281
- font-size: clamp(var(--fs-xl, 1.95rem), 3.5vw, var(--fs-3xl, 3.05rem));
323
+ --display-size: clamp(var(--fs-xl, 1.95rem), 3.5vw, var(--fs-3xl, 3.05rem));
324
+ font-size: var(--display-size);
282
325
  }
283
326
  .display-md {
284
327
  font-family: var(--font-display);
285
328
  font-weight: 600;
286
329
  line-height: 1.1;
287
330
  letter-spacing: -0.018em;
288
- font-size: clamp(var(--fs-lg, 1.56rem), 3vw, var(--fs-2xl, 2.44rem));
331
+ --display-size: clamp(var(--fs-lg, 1.56rem), 3vw, var(--fs-2xl, 2.44rem));
332
+ font-size: var(--display-size);
289
333
  }
334
+ h1:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h1, var(--display-size)); }
335
+ h2:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h2, var(--display-size)); }
336
+ h3:is(.display-xl, .display-lg, .display-md) { font-size: var(--heading-h3, var(--display-size)); }
290
337
 
291
338
  /* Tasteful scroll-reveal; opt-in via [data-reveal], JS toggles .is-in. */
292
339
  [data-reveal] {
@@ -311,8 +358,9 @@
311
358
 
312
359
  /* Rich text from CMS/description. */
313
360
  .rte > * + * { margin-top: 1em; }
314
- .rte h2 { font-size: var(--fs-xl, 1.6rem); margin-top: 1.6em; }
315
- .rte h3 { font-size: var(--fs-lg, 1.3rem); margin-top: 1.3em; }
361
+ .rte h1 { font-size: var(--heading-h1, 1em); }
362
+ .rte h2 { font-size: var(--heading-h2, var(--fs-xl, 1.6rem)); margin-top: 1.6em; }
363
+ .rte h3 { font-size: var(--heading-h3, var(--fs-lg, 1.3rem)); margin-top: 1.3em; }
316
364
  .rte ul, .rte ol { padding-left: 1.4em; }
317
365
  .rte ul { list-style: disc; }
318
366
  .rte ol { list-style: decimal; }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Color tokens the platform DERIVES from other tokens when a theme does not set
3
+ * them — the same definitions `src/styles/global.css` declares on :root (the
4
+ * relative-color forms of its @supports block). A custom property computes where
5
+ * it is declared, so a surface that re-skins a subtree by setting base tokens on
6
+ * an element (the style guides' `preview.componentVars`) re-declares these on the
7
+ * same element; otherwise they would keep the values derived from :root's
8
+ * palette. `derivedTokens.test.ts` pins this list to global.css.
9
+ */
10
+ export const DERIVED_COLOR_TOKENS: Readonly<Record<string, string>> = {
11
+ "--color-accent-ink": "oklch(from var(--color-accent) min(l, 0.5) c h)",
12
+ "--color-accent-contrast": "oklch(from var(--color-accent) clamp(0, (0.58 - l) * 1000, 1) 0 0)",
13
+ "--color-primary-hover": "color-mix(in srgb, var(--color-primary) 88%, #000)",
14
+ "--color-accent-hover": "color-mix(in srgb, var(--color-accent) 88%, #000)",
15
+ "--color-focus-ring": "var(--color-accent)",
16
+ "--color-disabled": "color-mix(in srgb, var(--color-muted) 26%, var(--color-surface))",
17
+ "--color-disabled-contrast": "var(--color-muted)",
18
+ "--color-field-border": "color-mix(in srgb, var(--color-border) 52%, var(--color-text))",
19
+ "--color-star": "var(--color-accent)",
20
+ "--color-cta": "var(--color-primary)",
21
+ "--color-cta-hover": "var(--color-primary-hover)",
22
+ "--color-cta-contrast": "var(--color-primary-contrast)",
23
+ "--color-trust": "var(--color-success)",
24
+ };
25
+
26
+ /**
27
+ * The derived declarations for a scope whose base tokens are `declared`: every
28
+ * derived token the scope does not set itself, as `name:value` pairs.
29
+ */
30
+ export function derivedTokenDeclarations(declared: Readonly<Record<string, string>>): string[] {
31
+ return Object.entries(DERIVED_COLOR_TOKENS)
32
+ .filter(([name]) => !(name in declared))
33
+ .map(([name, value]) => `${name}:${value}`);
34
+ }
@@ -6,6 +6,7 @@
6
6
  * into the `<style>` block the middleware injects per request, so one component
7
7
  * library renders every tenant's identity. See DESIGN.md.
8
8
  */
9
+ import { isThemeCssSize, THEME_HEADING_LEVELS } from "@tot/public-runtime";
9
10
  import type { DeepPartial, ThemeTokens } from "@tot/public-runtime";
10
11
 
11
12
  /** Deep-merge tenant overrides over the reference defaults. */
@@ -61,6 +62,11 @@ export function themeToCssVars(t: ThemeTokens): string {
61
62
  for (const [name, step] of Object.entries(steps)) {
62
63
  push(`fs-${name}`, `${(1 * Math.pow(ratio, step)).toFixed(4)}rem`);
63
64
  }
65
+ // Fixed heading sizes; global.css falls back to the fluid scale per level.
66
+ for (const level of THEME_HEADING_LEVELS) {
67
+ const size = t.type.headings?.[level];
68
+ if (isThemeCssSize(size)) push(`heading-${level}`, size.trim());
69
+ }
64
70
 
65
71
  // Space
66
72
  push("space-base", `${t.space.base}px`);
@@ -0,0 +1,164 @@
1
+ /** Shared helpers for the TenantPipeline harness tests (run inside workerd). */
2
+ import { env } from "cloudflare:test";
3
+ import { expect } from "vitest";
4
+ import type { JobState, JobView, TransitionView } from "../../src/lib/pipeline/job-runner";
5
+ import type { TenantPipeline } from "./pipeline-host";
6
+ import { S3_CHUNKS, SYNTHETIC_KIND, type Fault } from "./synthetic-job";
7
+
8
+ interface Fetcher {
9
+ fetch(input: string, init?: RequestInit): Promise<Response>;
10
+ }
11
+
12
+ interface HarnessEnv {
13
+ TENANT_PIPELINE: {
14
+ idFromName(name: string): unknown;
15
+ get(id: unknown): TenantPipelineStub;
16
+ };
17
+ /** Intake through the Worker hosting the object (its own isolate). */
18
+ PIPELINE_HOST: Fetcher;
19
+ /** Intake through a second Worker (another isolate) bound to the same namespace. */
20
+ INTAKE_B: Fetcher;
21
+ }
22
+
23
+ type TenantPipelineStub = {
24
+ [K in "submit" | "job" | "jobs" | "transitions" | "facts" | "harness" | "probeStaleWrites"]: TenantPipeline[K];
25
+ };
26
+
27
+ export const harnessEnv = env as unknown as HarnessEnv;
28
+
29
+ export function pipelineFor(tenant: string): TenantPipelineStub {
30
+ return harnessEnv.TENANT_PIPELINE.get(harnessEnv.TENANT_PIPELINE.idFromName(tenant));
31
+ }
32
+
33
+ let counter = 0;
34
+ export function uniqueTenant(label: string): string {
35
+ counter += 1;
36
+ return `${label}-${counter}-${crypto.randomUUID().slice(0, 8)}.example`;
37
+ }
38
+
39
+ export interface Submitted {
40
+ status: number;
41
+ jobId: string;
42
+ ms: number;
43
+ intake: string | null;
44
+ /** The job's state in the 202 body. */
45
+ state: string;
46
+ }
47
+
48
+ export async function submit(
49
+ tenant: string,
50
+ faults: Fault[] = [],
51
+ opts: { kind?: string; via?: Fetcher; idempotencyKey?: string } = {},
52
+ ): Promise<Submitted> {
53
+ const via = opts.via ?? harnessEnv.PIPELINE_HOST;
54
+ const started = performance.now();
55
+ const res = await via.fetch(`https://pipeline.test/intents/${encodeURIComponent(tenant)}`, {
56
+ method: "POST",
57
+ headers: { "content-type": "application/json" },
58
+ body: JSON.stringify({
59
+ kind: opts.kind ?? SYNTHETIC_KIND,
60
+ input: { faults },
61
+ actor: { type: "test", id: "harness" },
62
+ admissionDecisionId: null,
63
+ ...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
64
+ }),
65
+ });
66
+ const ms = performance.now() - started;
67
+ const body = (await res.json()) as { jobId: string; state: string };
68
+ return { status: res.status, jobId: body.jobId, ms, intake: res.headers.get("x-intake"), state: body.state };
69
+ }
70
+
71
+ const TERMINAL = new Set<JobState>(["succeeded", "failed", "stuck"]);
72
+
73
+ /**
74
+ * Polls until every job is terminal. On every sample asserts the tenant never has more than one
75
+ * running job. Create stubs for later reads after this returns (see the eviction note below).
76
+ */
77
+ export async function waitTerminal(tenant: string, jobIds: string[], timeoutMs = 20_000): Promise<JobView[]> {
78
+ const deadline = Date.now() + timeoutMs;
79
+ let maxRunning = 0;
80
+ for (;;) {
81
+ let jobs: JobView[];
82
+ try {
83
+ // A fresh stub per read: a stub to an object that was evicted (ctx.abort) stays broken,
84
+ // and a real caller re-creates it the same way.
85
+ jobs = await pipelineFor(tenant).jobs(1_000);
86
+ } catch (err) {
87
+ if (!String((err as Error).message).includes("synthetic eviction")) throw err;
88
+ await new Promise((resolve) => setTimeout(resolve, 25));
89
+ continue;
90
+ }
91
+ const running = jobs.filter((j) => j.state === "running").length;
92
+ maxRunning = Math.max(maxRunning, running);
93
+ expect(running, "running jobs observed at once").toBeLessThanOrEqual(1);
94
+ const wanted = jobIds.map((id) => jobs.find((j) => j.jobId === id));
95
+ if (wanted.every((j) => j && TERMINAL.has(j.state))) return wanted as JobView[];
96
+ if (Date.now() > deadline) {
97
+ throw new Error(`jobs not terminal after ${timeoutMs} ms: ${JSON.stringify(wanted.map((j) => j && [j.step, j.state, j.attempt]))}`);
98
+ }
99
+ await new Promise((resolve) => setTimeout(resolve, 25));
100
+ }
101
+ }
102
+
103
+ const OUTCOME_NOTES = ["step-done", "checkpoint", "lease-expired"];
104
+
105
+ /**
106
+ * The job's transition log is a complete chain: created queued, started once, every step start
107
+ * has exactly one recorded outcome (or is superseded by a resume after an eviction), each step
108
+ * finished exactly once and in order, and it ends at `terminal` with nothing after it.
109
+ */
110
+ export function assertCompleteLog(log: TransitionView[], terminal: JobState, stepNames = ["s1", "s2", "s3", "s4", "s5"]): void {
111
+ expect(log.length).toBeGreaterThan(2);
112
+ expect([log[0]!.from, log[0]!.to]).toEqual(["none", "queued"]);
113
+ expect([log[1]!.from, log[1]!.to]).toEqual(["queued", "running"]);
114
+ for (let i = 1; i < log.length; i += 1) {
115
+ expect(log[i]!.seq).toBeGreaterThan(log[i - 1]!.seq);
116
+ expect(log[i]!.from, `transition ${i} continues the chain`).toBe(log[i - 1]!.to);
117
+ }
118
+ const last = log[log.length - 1]!;
119
+ expect(last.to).toBe(terminal);
120
+ expect(log.filter((t) => TERMINAL.has(t.to as JobState))).toHaveLength(1);
121
+
122
+ // Pair each step start with what happened to it.
123
+ for (let i = 0; i < log.length; i += 1) {
124
+ const t = log[i]!;
125
+ if (!t.note?.startsWith("step-start")) continue;
126
+ const next = log[i + 1];
127
+ expect(next, `step-start ${t.step}#${t.attempt} has a recorded outcome`).toBeDefined();
128
+ const resolved =
129
+ (next!.step === t.step && next!.attempt === t.attempt &&
130
+ (OUTCOME_NOTES.includes(next!.note ?? "") || next!.note?.startsWith("step-failed"))) ||
131
+ next!.note === "step-start; previous execution did not finish" ||
132
+ TERMINAL.has(next!.to as JobState);
133
+ expect(resolved, `outcome of step-start ${t.step}#${t.attempt}: ${JSON.stringify(next)}`).toBe(true);
134
+ }
135
+
136
+ if (terminal === "succeeded") {
137
+ const done = log.filter((t) => t.note === "step-done").map((t) => t.step);
138
+ expect(done).toEqual(stepNames);
139
+ expect(log.filter((t) => t.note === "checkpoint" && t.step === "s3")).toHaveLength(S3_CHUNKS - 1);
140
+ }
141
+ }
142
+
143
+ /** Job B was started only after job A reached its terminal state. */
144
+ export function assertSerialized(all: TransitionView[], jobIds: string[]): void {
145
+ const spans = jobIds.map((jobId) => {
146
+ const own = all.filter((t) => t.jobId === jobId);
147
+ const start = own.find((t) => t.from === "queued" && t.to === "running");
148
+ const end = own.find((t) => TERMINAL.has(t.to as JobState));
149
+ expect(start, `job ${jobId} started`).toBeDefined();
150
+ expect(end, `job ${jobId} finished`).toBeDefined();
151
+ return { jobId, start: start!.seq, end: end!.seq };
152
+ });
153
+ spans.sort((a, b) => a.start - b.start);
154
+ for (let i = 1; i < spans.length; i += 1) {
155
+ expect(spans[i]!.start, `job ${spans[i]!.jobId} started before ${spans[i - 1]!.jobId} finished`).toBeGreaterThan(spans[i - 1]!.end);
156
+ }
157
+ // Every transition of a job lies inside its own span: no step of one job ran while another was running.
158
+ for (const span of spans) {
159
+ for (const t of all.filter((x) => x.jobId === span.jobId && x.from === "running")) {
160
+ expect(t.seq).toBeGreaterThan(span.start);
161
+ expect(t.seq).toBeLessThanOrEqual(span.end);
162
+ }
163
+ }
164
+ }
@@ -0,0 +1,14 @@
1
+ // A second intake Worker (its own isolate) that submits intents to the same TenantPipeline
2
+ // namespace as the host Worker, so concurrent intake is exercised across isolates.
3
+ export default {
4
+ async fetch(request, env) {
5
+ const match = /^\/intents\/([^/]+)$/.exec(new URL(request.url).pathname);
6
+ if (request.method !== "POST" || !match) return new Response("not found", { status: 404 });
7
+ const tenantId = decodeURIComponent(match[1]);
8
+ const stub = env.TENANT_PIPELINE.get(env.TENANT_PIPELINE.idFromName(tenantId));
9
+ const result = await stub.submit(tenantId, await request.json());
10
+ const headers = { "x-intake": "pipeline-intake-b" };
11
+ if (!result.ok) return Response.json({ error: result.code, reason: result.reason }, { status: 400, headers });
12
+ return Response.json({ jobId: result.jobId, state: result.state, duplicate: result.duplicate }, { status: 202, headers });
13
+ },
14
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The Worker that hosts the TenantPipeline Durable Object for the harness, in its own isolate
3
+ * (as in production, where callers never share the object's isolate — and where an eviction
4
+ * restarts the object without touching its callers). It runs the production engine with the
5
+ * synthetic job kinds registered and answers intents through `submitIntent`.
6
+ */
7
+ import type { DurableObjectState } from "cloudflare:workers";
8
+ import { TenantPipeline as DeployedTenantPipeline } from "../../src/lib/pipeline/tenant-pipeline";
9
+ import { submitIntent, type TenantPipelineNamespace } from "../../src/lib/pipeline/intake";
10
+ import { StaleExecutionError, type JobKindRegistry, type PipelineIntent } from "../../src/lib/pipeline/job-runner";
11
+ import { abandonedContexts, syntheticJobKinds } from "./synthetic-job";
12
+
13
+ export class TenantPipeline extends DeployedTenantPipeline {
14
+ protected override jobKinds(ctx: DurableObjectState): JobKindRegistry {
15
+ return syntheticJobKinds(ctx);
16
+ }
17
+
18
+ /** Harness-only reads of the synthetic step log. */
19
+ async harness(jobId: string): Promise<{
20
+ runs: { step: number; chunk: number; attempt: number }[];
21
+ effects: { step: number; chunk: number }[];
22
+ zombieWrites: string[];
23
+ }> {
24
+ const { sql } = this.ctx.storage;
25
+ return {
26
+ runs: sql
27
+ .exec<{ step: number; chunk: number; attempt: number }>(
28
+ `SELECT step, chunk, attempt FROM synthetic_runs WHERE job_id = ? ORDER BY seq`,
29
+ jobId,
30
+ )
31
+ .toArray(),
32
+ effects: sql
33
+ .exec<{ step: number; chunk: number }>(`SELECT step, chunk FROM synthetic_effects WHERE job_id = ? ORDER BY step, chunk`, jobId)
34
+ .toArray(),
35
+ zombieWrites: sql
36
+ .exec<{ outcome: string }>(`SELECT outcome FROM synthetic_zombie_writes WHERE job_id = ?`, jobId)
37
+ .toArray()
38
+ .map((r) => r.outcome),
39
+ };
40
+ }
41
+
42
+ /** Writes through the context of an execution abandoned at its lease; reports each outcome. */
43
+ async probeStaleWrites(jobId: string): Promise<string[]> {
44
+ const stale = abandonedContexts.find((c) => c.jobId === jobId);
45
+ if (!stale) return ["no-abandoned-execution"];
46
+ const attempts: [string, () => void][] = [
47
+ ["saveCheckpoint", () => stale.saveCheckpoint({ late: true })],
48
+ ["recordCommit", () => stale.recordCommit("sha-late", [])],
49
+ ["recordEvidence", () => stale.recordEvidence("sha-late", "digest-late", true)],
50
+ ["recordLive", () => stale.recordLive("s3-overwrite", "sha-late", "rcpt-late", null)],
51
+ ];
52
+ return attempts.map(([name, write]) => {
53
+ try {
54
+ write();
55
+ return `${name}:accepted`;
56
+ } catch (err) {
57
+ return `${name}:${err instanceof StaleExecutionError ? "refused" : (err as Error).name}`;
58
+ }
59
+ });
60
+ }
61
+ }
62
+
63
+ interface Env {
64
+ TENANT_PIPELINE: TenantPipelineNamespace;
65
+ }
66
+
67
+ export default {
68
+ async fetch(request: Request, env: Env): Promise<Response> {
69
+ const match = /^\/intents\/([^/]+)$/.exec(new URL(request.url).pathname);
70
+ if (request.method !== "POST" || !match) return new Response("not found", { status: 404 });
71
+ const response = await submitIntent(env.TENANT_PIPELINE, decodeURIComponent(match[1]!), (await request.json()) as PipelineIntent);
72
+ return new Response(response.body, {
73
+ status: response.status,
74
+ headers: { ...Object.fromEntries(response.headers), "x-intake": "pipeline-host" },
75
+ });
76
+ },
77
+ };