@tokenoftrust/storefront-runner 2.2.125 → 2.3.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/apps/storefront/astro.config.mjs +5 -4
- package/apps/storefront/package.json +3 -1
- package/apps/storefront/public/shared/commerce-chrome.css +57 -15
- package/apps/storefront/public/shared/commerce-marketing.css +7 -7
- package/apps/storefront/src/components/Badge.astro +1 -1
- package/apps/storefront/src/components/Breadcrumbs.astro +8 -3
- package/apps/storefront/src/components/Section.astro +4 -1
- package/apps/storefront/src/components/Seo.astro +7 -5
- package/apps/storefront/src/components/blog/BlogIndexView.astro +22 -13
- package/apps/storefront/src/components/blog/ShareRow.astro +43 -0
- package/apps/storefront/src/components/chrome/AnnouncementBar.astro +1 -1
- package/apps/storefront/src/components/chrome/NavDropdown.astro +7 -7
- package/apps/storefront/src/components/chrome/SiteFooter.astro +17 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +5 -5
- package/apps/storefront/src/components/commerce/TagFilterCloud.astro +1 -1
- package/apps/storefront/src/components/compliance/NicotineWarning.astro +12 -22
- package/apps/storefront/src/components/compliance/PactActNotice.astro +9 -10
- package/apps/storefront/src/components/content/ProseSections.astro +2 -2
- package/apps/storefront/src/components/home/Hero.astro +34 -12
- package/apps/storefront/src/components/islands/IsolatedAgeGate.tsx +39 -5
- package/apps/storefront/src/components/islands/VariantSelector.tsx +3 -3
- package/apps/storefront/src/components/marketing/CardGrid.astro +1 -1
- package/apps/storefront/src/components/marketing/CodeSample.astro +1 -1
- package/apps/storefront/src/components/marketing/CtaBand.astro +1 -1
- package/apps/storefront/src/components/marketing/Faq.astro +1 -1
- package/apps/storefront/src/components/marketing/Integrations.astro +1 -1
- package/apps/storefront/src/components/marketing/MarketingCtas.astro +1 -1
- package/apps/storefront/src/components/marketing/MarketingHero.astro +1 -1
- package/apps/storefront/src/components/marketing/ProofStrip.astro +1 -1
- package/apps/storefront/src/components/marketing/SplitCompare.astro +1 -1
- package/apps/storefront/src/components/marketing/Steps.astro +1 -1
- package/apps/storefront/src/components/marketing/Testimonials.astro +2 -2
- package/apps/storefront/src/components/marketing/TrustBar.astro +1 -1
- package/apps/storefront/src/components/membership/TierComparisonTable.astro +1 -1
- package/apps/storefront/src/components/plp/ActiveFilters.astro +1 -1
- package/apps/storefront/src/components/plp/FacetSidebar.astro +4 -4
- package/apps/storefront/src/components/plp/Pagination.astro +1 -1
- package/apps/storefront/src/components/plp/SortSelect.astro +1 -1
- package/apps/storefront/src/components/style-guide/StyleGuideNav.astro +18 -3
- package/apps/storefront/src/components/the-build/TheBuildIndexView.astro +9 -5
- package/apps/storefront/src/config/devTenantSeed.ts +20 -34
- package/apps/storefront/src/config/resolver.ts +0 -38
- package/apps/storefront/src/layouts/Layout.astro +8 -1
- package/apps/storefront/src/lib/assets/preview-version-cookie.ts +52 -5
- package/apps/storefront/src/lib/blog/pagination.ts +20 -10
- package/apps/storefront/src/lib/blog/presentation.ts +94 -0
- package/apps/storefront/src/lib/blog/rss.ts +1 -1
- package/apps/storefront/src/lib/blog/types.ts +24 -7
- package/apps/storefront/src/lib/breadcrumbs.ts +25 -0
- package/apps/storefront/src/lib/chrome/model.ts +4 -0
- package/apps/storefront/src/lib/cloudflare-workers.d.ts +53 -0
- package/apps/storefront/src/lib/compliance/rawComplianceNotices.ts +38 -18
- package/apps/storefront/src/lib/dashboard/delegateGate.ts +28 -0
- package/apps/storefront/src/lib/homeVisualParity.ts +8 -5
- package/apps/storefront/src/lib/jsonld.ts +5 -5
- package/apps/storefront/src/lib/pinnedRouteRewriteBoundary.ts +20 -2
- package/apps/storefront/src/lib/rawChrome.ts +74 -12
- package/apps/storefront/src/lib/runtimeEnv.ts +1 -1
- package/apps/storefront/src/lib/seo/documentTitle.ts +19 -0
- package/apps/storefront/src/lib/shared/addToRelease.ts +32 -0
- package/apps/storefront/src/lib/storyblok/content-model.ts +3 -0
- package/apps/storefront/src/lib/styleGuideThemes.ts +20 -22
- package/apps/storefront/src/pages/[...slug].astro +13 -8
- package/apps/storefront/src/pages/blog/[slug].astro +56 -24
- package/apps/storefront/src/pages/blog/author/[author].astro +17 -9
- package/apps/storefront/src/pages/blog/category/[category].astro +18 -10
- package/apps/storefront/src/pages/blog/index.astro +2 -0
- package/apps/storefront/src/pages/blog/page/[n].astro +2 -0
- package/apps/storefront/src/pages/blog/tag/[tag].astro +18 -10
- package/apps/storefront/src/pages/collections/[handle].astro +5 -4
- package/apps/storefront/src/pages/collections/index.astro +4 -3
- package/apps/storefront/src/pages/index.astro +34 -27
- package/apps/storefront/src/pages/products/[handle].astro +5 -4
- package/apps/storefront/src/pages/saved.astro +4 -3
- package/apps/storefront/src/pages/search.astro +4 -3
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +129 -118
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +19 -15
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +23 -27
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -14
- package/apps/storefront/src/pages/style-guide/index.astro +6 -5
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +32 -7
- package/apps/storefront/src/pages/the-build/[slug].astro +44 -20
- package/apps/storefront/src/pages/the-build/index.astro +2 -0
- package/apps/storefront/src/pages/the-build/page/[n].astro +2 -0
- package/apps/storefront/src/styles/admin.css +47 -0
- package/apps/storefront/src/styles/global.css +104 -56
- package/apps/storefront/src/themes/derivedTokens.ts +34 -0
- package/apps/storefront/src/themes/schema.ts +6 -0
- package/apps/storefront/test/pipeline/harness.ts +164 -0
- package/apps/storefront/test/pipeline/intake-worker.mjs +14 -0
- package/apps/storefront/test/pipeline/pipeline-host.ts +77 -0
- package/apps/storefront/test/pipeline/synthetic-job.ts +156 -0
- package/apps/storefront/vitest.workers.config.cts +67 -0
- package/docs/widget-library-guide.md +1496 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +400 -5
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/src/chrome.ts +93 -2
- package/packages/public-runtime/src/compliance-coherence.d.mts +17 -0
- package/packages/public-runtime/src/compliance-coherence.mjs +84 -0
- package/packages/public-runtime/src/declared-tenant-config.d.mts +1 -1
- package/packages/public-runtime/src/declared-tenant-config.mjs +6 -2
- package/packages/public-runtime/src/extension-contract-values.d.mts +4 -1
- package/packages/public-runtime/src/extension-contract-values.mjs +14 -1
- package/packages/public-runtime/src/extension-contract.ts +2 -0
- package/packages/public-runtime/src/tenant.ts +48 -4
- package/pnpm-lock.runner.yaml +469 -0
- 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
|
-
*
|
|
15
|
-
*
|
|
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:
|
|
19
|
-
--color-surface:
|
|
20
|
-
--color-text:
|
|
21
|
-
--color-muted:
|
|
22
|
-
--color-primary:
|
|
23
|
-
--color-primary-contrast:
|
|
24
|
-
--color-accent:
|
|
25
|
-
/*
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
--color-
|
|
33
|
-
|
|
34
|
-
|
|
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:
|
|
40
|
-
--color-warning:
|
|
41
|
-
--color-error:
|
|
42
|
-
--color-info:
|
|
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:
|
|
47
|
-
--color-accent-hover:
|
|
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-
|
|
57
|
-
--color-disabled:
|
|
58
|
-
--color-disabled-contrast: var(--color-
|
|
59
|
-
--color-field-border:
|
|
60
|
-
--color-star: var(--color-
|
|
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-
|
|
71
|
-
--color-cta-hover: var(--color-
|
|
72
|
-
--color-cta-contrast: var(--color-
|
|
73
|
-
--color-trust: var(--color-
|
|
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:
|
|
76
|
-
--font-body:
|
|
77
|
-
--font-mono:
|
|
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:
|
|
80
|
-
--radius-md:
|
|
81
|
-
--radius-lg:
|
|
82
|
-
--radius-full:
|
|
89
|
+
--radius-sm: 3px;
|
|
90
|
+
--radius-md: 6px;
|
|
91
|
+
--radius-lg: 12px;
|
|
92
|
+
--radius-full: 999px;
|
|
83
93
|
|
|
84
|
-
--shadow-sm:
|
|
85
|
-
--shadow-md:
|
|
86
|
-
--shadow-lg:
|
|
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:
|
|
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
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
--tap-target:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
.rte
|
|
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
|
+
};
|